Compare commits

...

21 Commits

Author SHA1 Message Date
Dmitry Popov
9a8dc4dbe5 Merge branch 'main' into tests-fixes 2025-11-22 12:29:22 +01:00
Dmitry Popov
7eb6d093cf fix(core): invalidate map characters every 1 hour for any missing/revoked permissions 2025-11-22 12:25:24 +01:00
CI
a23e544a9f chore: [skip ci] 2025-11-22 09:42:11 +00:00
CI
845ea7a576 chore: release version v1.85.3 2025-11-22 09:42:11 +00:00
Dmitry Popov
ae8fbf30e4 fix(core): fixed connection time status issues. fixed character alliance update issues 2025-11-22 10:41:35 +01:00
CI
3de385c902 chore: [skip ci] 2025-11-20 10:57:05 +00:00
CI
5f3d4dba37 chore: release version v1.85.2 2025-11-20 10:57:05 +00:00
Dmitry Popov
8acc7ddc25 fix(core): increased API pool limits 2025-11-20 11:56:31 +01:00
CI
ed6d25f3ea chore: [skip ci] 2025-11-20 10:35:09 +00:00
CI
ab07d1321d chore: release version v1.85.1 2025-11-20 10:35:09 +00:00
Dmitry Popov
a81e61bd70 Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-11-20 11:31:39 +01:00
Dmitry Popov
d2d33619c2 fix(core): increased API pool limits 2025-11-20 11:31:36 +01:00
CI
fa464110c6 chore: [skip ci] 2025-11-19 23:13:02 +00:00
CI
a5fa60e699 chore: release version v1.85.0 2025-11-19 23:13:02 +00:00
Dmitry Popov
6db994852f feat(core): added support for new ship types 2025-11-20 00:12:30 +01:00
CI
0a68676957 chore: [skip ci] 2025-11-19 21:06:28 +00:00
CI
9b82dd8f43 chore: release version v1.84.37 2025-11-19 21:06:28 +00:00
Dmitry Popov
aac2c33fd2 fix(auth): fixed character auth issues 2025-11-19 22:05:49 +01:00
CI
1665b65619 chore: [skip ci] 2025-11-19 10:33:10 +00:00
Dmitry Popov
5e0965ead4 fix(tests): updated tests 2025-11-17 12:52:11 +01:00
Dmitry Popov
4c39c6fb39 fix(tests): updated tests 2025-11-17 00:09:10 +01:00
44 changed files with 1764 additions and 871 deletions

View File

@@ -1,9 +1,9 @@
name: Build Docker Image
name: Build Develop
on:
push:
tags:
- '**'
branches:
- develop
env:
MIX_ENV: prod
@@ -18,12 +18,85 @@ permissions:
contents: write
jobs:
build:
name: 🛠 Build
runs-on: ubuntu-22.04
if: ${{ github.ref == 'refs/heads/develop' && github.event_name == 'push' }}
permissions:
checks: write
contents: write
packages: write
attestations: write
id-token: write
pull-requests: write
repository-projects: write
strategy:
matrix:
otp: ["27"]
elixir: ["1.17"]
node-version: ["18.x"]
outputs:
commit_hash: ${{ steps.set-commit-develop.outputs.commit_hash }}
steps:
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Setup Elixir
uses: erlef/setup-beam@v1
with:
otp-version: ${{matrix.otp}}
elixir-version: ${{matrix.elixir}}
# nix build would also work here because `todos` is the default package
- name: ⬇️ Checkout repo
uses: actions/checkout@v3
with:
ssh-key: "${{ secrets.COMMIT_KEY }}"
fetch-depth: 0
- name: 😅 Cache deps
id: cache-deps
uses: actions/cache@v4
env:
cache-name: cache-elixir-deps
with:
path: |
deps
key: ${{ runner.os }}-mix-${{ matrix.elixir }}-${{ matrix.otp }}-${{ hashFiles('**/mix.lock') }}
restore-keys: |
${{ runner.os }}-mix-${{ matrix.elixir }}-${{ matrix.otp }}-
- name: 😅 Cache compiled build
id: cache-build
uses: actions/cache@v4
env:
cache-name: cache-compiled-build
with:
path: |
_build
key: ${{ runner.os }}-build-${{ hashFiles('**/mix.lock') }}-${{ hashFiles( '**/lib/**/*.{ex,eex}', '**/config/*.exs', '**/mix.exs' ) }}
restore-keys: |
${{ runner.os }}-build-${{ hashFiles('**/mix.lock') }}-
${{ runner.os }}-build-
# Step: Download project dependencies. If unchanged, uses
# the cached version.
- name: 🌐 Install dependencies
run: mix deps.get --only "prod"
# Step: Compile the project treating any warnings as errors.
# Customize this step if a different behavior is desired.
- name: 🛠 Compiles without warnings
if: steps.cache-build.outputs.cache-hit != 'true'
run: mix compile
- name: Set commit hash for develop
id: set-commit-develop
run: |
echo "commit_hash=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
docker:
name: 🛠 Build Docker Images
needs: build
runs-on: ubuntu-22.04
outputs:
release-tag: ${{ steps.get-latest-tag.outputs.tag }}
release-notes: ${{ steps.get-content.outputs.string }}
permissions:
checks: write
contents: write
@@ -37,6 +110,7 @@ jobs:
matrix:
platform:
- linux/amd64
- linux/arm64
steps:
- name: Prepare
run: |
@@ -46,25 +120,9 @@ jobs:
- name: ⬇️ Checkout repo
uses: actions/checkout@v3
with:
ref: ${{ needs.build.outputs.commit_hash }}
fetch-depth: 0
- name: Get Release Tag
id: get-latest-tag
uses: "WyriHaximus/github-action-get-previous-tag@v1"
with:
fallback: 1.0.0
- name: ⬇️ Checkout repo
uses: actions/checkout@v3
with:
ref: ${{ steps.get-latest-tag.outputs.tag }}
fetch-depth: 0
- name: Prepare Changelog
run: |
yes | cp -rf CHANGELOG.md priv/changelog/CHANGELOG.md
sed -i '1i%{title: "Change Log"}\n\n---\n' priv/changelog/CHANGELOG.md
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
@@ -113,24 +171,6 @@ jobs:
if-no-files-found: error
retention-days: 1
- uses: markpatterson27/markdown-to-output@v1
id: extract-changelog
with:
filepath: CHANGELOG.md
- name: Get content
uses: 2428392/gh-truncate-string-action@v1.3.0
id: get-content
with:
stringToTruncate: |
📣 Wanderer new release available 🎉
**Version**: ${{ steps.get-latest-tag.outputs.tag }}
${{ steps.extract-changelog.outputs.body }}
maxLength: 500
truncationSymbol: "…"
merge:
runs-on: ubuntu-latest
needs:
@@ -161,9 +201,8 @@ jobs:
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{version}},value=${{ needs.docker.outputs.release-tag }}
type=raw,value=develop,enable=${{ github.ref == 'refs/heads/develop' }}
type=raw,value=develop-{{sha}},enable=${{ github.ref == 'refs/heads/develop' }}
- name: Create manifest list and push
working-directory: /tmp/digests
@@ -176,12 +215,20 @@ jobs:
docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.meta.outputs.version }}
notify:
name: 🏷 Notify about release
name: 🏷 Notify about develop release
runs-on: ubuntu-22.04
needs: [docker, merge]
steps:
- name: Discord Webhook Action
uses: tsickert/discord-webhook@v5.3.0
with:
webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }}
content: ${{ needs.docker.outputs.release-notes }}
webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL_DEV }}
content: |
📣 New develop release available 🚀
**Commit**: `${{ github.sha }}`
**Status**: Development/Testing Release
Docker image: `wandererltd/community-edition:develop`
⚠️ This is an unstable development release for testing purposes.

View File

@@ -4,7 +4,6 @@ on:
push:
branches:
- main
- develop
env:
MIX_ENV: prod
@@ -22,7 +21,7 @@ jobs:
build:
name: 🛠 Build
runs-on: ubuntu-22.04
if: ${{ (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop') && github.event_name == 'push' }}
if: ${{ github.ref == 'refs/heads/main' && github.event_name == 'push' }}
permissions:
checks: write
contents: write
@@ -37,7 +36,7 @@ jobs:
elixir: ["1.17"]
node-version: ["18.x"]
outputs:
commit_hash: ${{ steps.generate-changelog.outputs.commit_hash || steps.set-commit-develop.outputs.commit_hash }}
commit_hash: ${{ steps.generate-changelog.outputs.commit_hash }}
steps:
- name: Prepare
run: |
@@ -91,7 +90,6 @@ jobs:
- name: Generate Changelog & Update Tag Version
id: generate-changelog
if: github.ref == 'refs/heads/main'
run: |
git config --global user.name 'CI'
git config --global user.email 'ci@users.noreply.github.com'
@@ -102,15 +100,16 @@ jobs:
- name: Set commit hash for develop
id: set-commit-develop
if: github.ref == 'refs/heads/develop'
run: |
echo "commit_hash=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
docker:
name: 🛠 Build Docker Images
if: github.ref == 'refs/heads/develop'
needs: build
runs-on: ubuntu-22.04
outputs:
release-tag: ${{ steps.get-latest-tag.outputs.tag }}
release-notes: ${{ steps.get-content.outputs.string }}
permissions:
checks: write
contents: write
@@ -137,6 +136,17 @@ jobs:
ref: ${{ needs.build.outputs.commit_hash }}
fetch-depth: 0
- name: Get Release Tag
id: get-latest-tag
uses: "WyriHaximus/github-action-get-previous-tag@v1"
with:
fallback: 1.0.0
- name: Prepare Changelog
run: |
yes | cp -rf CHANGELOG.md priv/changelog/CHANGELOG.md
sed -i '1i%{title: "Change Log"}\n\n---\n' priv/changelog/CHANGELOG.md
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
@@ -185,6 +195,24 @@ jobs:
if-no-files-found: error
retention-days: 1
- uses: markpatterson27/markdown-to-output@v1
id: extract-changelog
with:
filepath: CHANGELOG.md
- name: Get content
uses: 2428392/gh-truncate-string-action@v1.3.0
id: get-content
with:
stringToTruncate: |
📣 Wanderer new release available 🎉
**Version**: ${{ steps.get-latest-tag.outputs.tag }}
${{ steps.extract-changelog.outputs.body }}
maxLength: 500
truncationSymbol: "…"
merge:
runs-on: ubuntu-latest
needs:
@@ -215,8 +243,9 @@ jobs:
tags: |
type=ref,event=branch
type=ref,event=pr
type=raw,value=develop,enable=${{ github.ref == 'refs/heads/develop' }}
type=raw,value=develop-{{sha}},enable=${{ github.ref == 'refs/heads/develop' }}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{version}},value=${{ needs.docker.outputs.release-tag }}
- name: Create manifest list and push
working-directory: /tmp/digests
@@ -259,3 +288,14 @@ jobs:
## How to Promote?
In order to promote this to prod, edit the draft and press **"Publish release"**.
draft: true
notify:
name: 🏷 Notify about release
runs-on: ubuntu-22.04
needs: [docker, merge]
steps:
- name: Discord Webhook Action
uses: tsickert/discord-webhook@v5.3.0
with:
webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }}
content: ${{ needs.docker.outputs.release-notes }}

View File

@@ -1,187 +0,0 @@
name: Build Docker ARM Image
on:
push:
tags:
- '**'
env:
MIX_ENV: prod
GH_TOKEN: ${{ github.token }}
REGISTRY_IMAGE: wandererltd/community-edition-arm
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: write
jobs:
docker:
name: 🛠 Build Docker Images
runs-on: ubuntu-22.04
outputs:
release-tag: ${{ steps.get-latest-tag.outputs.tag }}
release-notes: ${{ steps.get-content.outputs.string }}
permissions:
checks: write
contents: write
packages: write
attestations: write
id-token: write
pull-requests: write
repository-projects: write
strategy:
fail-fast: false
matrix:
platform:
- linux/arm64
steps:
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: ⬇️ Checkout repo
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Get Release Tag
id: get-latest-tag
uses: "WyriHaximus/github-action-get-previous-tag@v1"
with:
fallback: 1.0.0
- name: ⬇️ Checkout repo
uses: actions/checkout@v3
with:
ref: ${{ steps.get-latest-tag.outputs.tag }}
fetch-depth: 0
- name: Prepare Changelog
run: |
yes | cp -rf CHANGELOG.md priv/changelog/CHANGELOG.md
sed -i '1i%{title: "Change Log"}\n\n---\n' priv/changelog/CHANGELOG.md
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY_IMAGE }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.WANDERER_DOCKER_USER }}
password: ${{ secrets.WANDERER_DOCKER_PASSWORD }}
- name: Build and push
id: build
uses: docker/build-push-action@v6
with:
push: true
context: .
file: ./Dockerfile
cache-from: type=gha
cache-to: type=gha,mode=max
labels: ${{ steps.meta.outputs.labels }}
platforms: ${{ matrix.platform }}
outputs: type=image,"name=${{ env.REGISTRY_IMAGE }}",push-by-digest=true,name-canonical=true,push=true
build-args: |
MIX_ENV=prod
BUILD_METADATA=${{ steps.meta.outputs.json }}
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
- uses: markpatterson27/markdown-to-output@v1
id: extract-changelog
with:
filepath: CHANGELOG.md
- name: Get content
uses: 2428392/gh-truncate-string-action@v1.3.0
id: get-content
with:
stringToTruncate: |
📣 Wanderer **ARM** release available 🎉
**Version**: :${{ steps.get-latest-tag.outputs.tag }}
${{ steps.extract-changelog.outputs.body }}
maxLength: 500
truncationSymbol: "…"
merge:
runs-on: ubuntu-latest
needs:
- docker
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.WANDERER_DOCKER_USER }}
password: ${{ secrets.WANDERER_DOCKER_PASSWORD }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.REGISTRY_IMAGE }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{version}},value=${{ needs.docker.outputs.release-tag }}
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.meta.outputs.version }}
notify:
name: 🏷 Notify about release
runs-on: ubuntu-22.04
needs: [docker, merge]
steps:
- name: Discord Webhook Action
uses: tsickert/discord-webhook@v5.3.0
with:
webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }}
content: ${{ needs.docker.outputs.release-notes }}

View File

@@ -2,6 +2,51 @@
<!-- changelog -->
## [v1.85.3](https://github.com/wanderer-industries/wanderer/compare/v1.85.2...v1.85.3) (2025-11-22)
### Bug Fixes:
* core: fixed connection time status issues. fixed character alliance update issues
## [v1.85.2](https://github.com/wanderer-industries/wanderer/compare/v1.85.1...v1.85.2) (2025-11-20)
### Bug Fixes:
* core: increased API pool limits
## [v1.85.1](https://github.com/wanderer-industries/wanderer/compare/v1.85.0...v1.85.1) (2025-11-20)
### Bug Fixes:
* core: increased API pool limits
## [v1.85.0](https://github.com/wanderer-industries/wanderer/compare/v1.84.37...v1.85.0) (2025-11-19)
### Features:
* core: added support for new ship types
## [v1.84.37](https://github.com/wanderer-industries/wanderer/compare/v1.84.36...v1.84.37) (2025-11-19)
### Bug Fixes:
* auth: fixed character auth issues
## [v1.84.36](https://github.com/wanderer-industries/wanderer/compare/v1.84.35...v1.84.36) (2025-11-19)

View File

@@ -177,7 +177,34 @@ config :wanderer_app,
],
extra_characters_50: map_subscription_extra_characters_50_price,
extra_hubs_10: map_subscription_extra_hubs_10_price
}
},
# Finch pool configuration - separate pools for different services
# ESI Character Tracking pool - high capacity for bulk character operations
# With 30+ TrackerPools × ~100 concurrent tasks, need large pool
finch_esi_character_pool_size:
System.get_env("WANDERER_FINCH_ESI_CHARACTER_POOL_SIZE", "200") |> String.to_integer(),
finch_esi_character_pool_count:
System.get_env("WANDERER_FINCH_ESI_CHARACTER_POOL_COUNT", "4") |> String.to_integer(),
# ESI General pool - standard capacity for general ESI operations
finch_esi_general_pool_size:
System.get_env("WANDERER_FINCH_ESI_GENERAL_POOL_SIZE", "50") |> String.to_integer(),
finch_esi_general_pool_count:
System.get_env("WANDERER_FINCH_ESI_GENERAL_POOL_COUNT", "4") |> String.to_integer(),
# Webhooks pool - isolated from ESI rate limits
finch_webhooks_pool_size:
System.get_env("WANDERER_FINCH_WEBHOOKS_POOL_SIZE", "25") |> String.to_integer(),
finch_webhooks_pool_count:
System.get_env("WANDERER_FINCH_WEBHOOKS_POOL_COUNT", "2") |> String.to_integer(),
# Default pool - everything else (email, license manager, etc.)
finch_default_pool_size:
System.get_env("WANDERER_FINCH_DEFAULT_POOL_SIZE", "25") |> String.to_integer(),
finch_default_pool_count:
System.get_env("WANDERER_FINCH_DEFAULT_POOL_COUNT", "2") |> String.to_integer(),
# Character tracker concurrency settings
# Location updates need high concurrency for <2s response with 3000+ characters
location_concurrency:
System.get_env("WANDERER_LOCATION_CONCURRENCY", "#{System.schedulers_online() * 12}")
|> String.to_integer()
config :ueberauth, Ueberauth,
providers: [

View File

@@ -16,15 +16,48 @@ defmodule WandererApp.Application do
WandererApp.Vault,
WandererApp.Repo,
{Phoenix.PubSub, name: WandererApp.PubSub, adapter_name: Phoenix.PubSub.PG2},
# Multiple Finch pools for different services to prevent connection pool exhaustion
# ESI Character Tracking pool - high capacity for bulk character operations
{
Finch,
name: WandererApp.Finch.ESI.CharacterTracking,
pools: %{
default: [
size: Application.get_env(:wanderer_app, :finch_esi_character_pool_size, 100),
count: Application.get_env(:wanderer_app, :finch_esi_character_pool_count, 4)
]
}
},
# ESI General pool - standard capacity for general ESI operations
{
Finch,
name: WandererApp.Finch.ESI.General,
pools: %{
default: [
size: Application.get_env(:wanderer_app, :finch_esi_general_pool_size, 50),
count: Application.get_env(:wanderer_app, :finch_esi_general_pool_count, 4)
]
}
},
# Webhooks pool - isolated from ESI rate limits
{
Finch,
name: WandererApp.Finch.Webhooks,
pools: %{
default: [
size: Application.get_env(:wanderer_app, :finch_webhooks_pool_size, 25),
count: Application.get_env(:wanderer_app, :finch_webhooks_pool_count, 2)
]
}
},
# Default pool - everything else (email, license manager, etc.)
{
Finch,
name: WandererApp.Finch,
pools: %{
default: [
# number of connections per pool
size: 50,
# number of pools (so total 50 connections)
count: 4
size: Application.get_env(:wanderer_app, :finch_default_pool_size, 25),
count: Application.get_env(:wanderer_app, :finch_default_pool_count, 2)
]
}
},

View File

@@ -31,13 +31,68 @@ defmodule WandererApp.CachedInfo do
)
end)
Cachex.get(:ship_types_cache, type_id)
get_ship_type_from_cache_or_api(type_id)
{:ok, ship_type} ->
{:ok, ship_type}
end
end
defp get_ship_type_from_cache_or_api(type_id) do
case Cachex.get(:ship_types_cache, type_id) do
{:ok, ship_type} when not is_nil(ship_type) ->
{:ok, ship_type}
{:ok, nil} ->
case WandererApp.Esi.get_type_info(type_id) do
{:ok, info} when not is_nil(info) ->
ship_type = parse_type(type_id, info)
{:ok, group_info} = get_group_info(ship_type.group_id)
{:ok, ship_type_info} =
WandererApp.Api.ShipTypeInfo |> Ash.create(ship_type |> Map.merge(group_info))
{:ok,
ship_type_info
|> Map.take([
:type_id,
:group_id,
:group_name,
:name,
:description,
:mass,
:capacity,
:volume
])}
{:error, reason} ->
Logger.error("Failed to get ship_type #{type_id} from ESI: #{inspect(reason)}")
{:ok, nil}
error ->
Logger.error("Failed to get ship_type #{type_id} from ESI: #{inspect(error)}")
{:ok, nil}
end
end
end
def get_group_info(nil), do: {:ok, nil}
def get_group_info(group_id) do
case WandererApp.Esi.get_group_info(group_id) do
{:ok, info} when not is_nil(info) ->
{:ok, parse_group(group_id, info)}
{:error, reason} ->
Logger.error("Failed to get group_info #{group_id} from ESI: #{inspect(reason)}")
{:ok, %{group_name: ""}}
error ->
Logger.error("Failed to get group_info #{group_id} from ESI: #{inspect(error)}")
{:ok, %{group_name: ""}}
end
end
def get_system_static_info(solar_system_id) do
{:ok, solar_system_id} = APIUtils.parse_int(solar_system_id)
@@ -153,6 +208,25 @@ defmodule WandererApp.CachedInfo do
end
end
defp parse_group(group_id, group) do
%{
group_id: group_id,
group_name: Map.get(group, "name")
}
end
defp parse_type(type_id, type) do
%{
type_id: type_id,
name: Map.get(type, "name"),
description: Map.get(type, "description"),
group_id: Map.get(type, "group_id"),
mass: "#{Map.get(type, "mass")}",
capacity: "#{Map.get(type, "capacity")}",
volume: "#{Map.get(type, "volume")}"
}
end
defp build_jump_index() do
case get_solar_system_jumps() do
{:ok, jumps} ->

View File

@@ -331,7 +331,7 @@ defmodule WandererApp.Character do
do:
{:ok,
Enum.map(eve_ids, fn eve_id ->
Task.async(fn -> apply(WandererApp.Esi.ApiClient, method, [eve_id]) end)
Task.async(fn -> apply(WandererApp.Esi, method, [eve_id]) end)
end)
# 145000 == Timeout in milliseconds
|> Enum.map(fn task -> Task.await(task, 145_000) end)

View File

@@ -709,6 +709,7 @@ defmodule WandererApp.Character.Tracker do
end
end
# when old_alliance_id != alliance_id and is_nil(alliance_id)
defp maybe_update_alliance(
%{character_id: character_id, alliance_id: old_alliance_id} = state,
alliance_id
@@ -734,6 +735,7 @@ defmodule WandererApp.Character.Tracker do
)
state
|> Map.merge(%{alliance_id: nil})
end
defp maybe_update_alliance(
@@ -771,6 +773,7 @@ defmodule WandererApp.Character.Tracker do
)
state
|> Map.merge(%{alliance_id: alliance_id})
_error ->
Logger.error("Failed to get alliance info for #{alliance_id}")

View File

@@ -8,7 +8,8 @@ defmodule WandererApp.Character.TrackerPool do
:tracked_ids,
:uuid,
:characters,
server_online: false
server_online: false,
last_location_duration: 0
]
@name __MODULE__
@@ -23,6 +24,15 @@ defmodule WandererApp.Character.TrackerPool do
@update_info_interval :timer.minutes(2)
@update_wallet_interval :timer.minutes(10)
# Per-operation concurrency limits
# Location updates are critical and need high concurrency (100 chars in ~200ms)
# Note: This is fetched at runtime since it's configured via runtime.exs
defp location_concurrency do
Application.get_env(:wanderer_app, :location_concurrency, System.schedulers_online() * 12)
end
# Other operations can use lower concurrency
@standard_concurrency System.schedulers_online() * 2
@logger Application.compile_env(:wanderer_app, :logger)
def new(), do: __struct__()
@@ -106,14 +116,23 @@ defmodule WandererApp.Character.TrackerPool do
"server_status"
)
Process.send_after(self(), :update_online, 100)
Process.send_after(self(), :update_location, 300)
Process.send_after(self(), :update_ship, 500)
Process.send_after(self(), :update_info, 1500)
# Stagger pool startups to distribute load across multiple pools
# Critical location updates get minimal stagger (0-500ms)
# Other operations get wider stagger (0-10s) to reduce thundering herd
location_stagger = :rand.uniform(500)
online_stagger = :rand.uniform(10_000)
ship_stagger = :rand.uniform(10_000)
info_stagger = :rand.uniform(60_000)
Process.send_after(self(), :update_online, 100 + online_stagger)
Process.send_after(self(), :update_location, 300 + location_stagger)
Process.send_after(self(), :update_ship, 500 + ship_stagger)
Process.send_after(self(), :update_info, 1500 + info_stagger)
Process.send_after(self(), :check_offline_characters, @check_offline_characters_interval)
if WandererApp.Env.wallet_tracking_enabled?() do
Process.send_after(self(), :update_wallet, 1000)
wallet_stagger = :rand.uniform(120_000)
Process.send_after(self(), :update_wallet, 1000 + wallet_stagger)
end
{:noreply, state}
@@ -163,7 +182,7 @@ defmodule WandererApp.Character.TrackerPool do
fn character_id ->
WandererApp.Character.Tracker.update_online(character_id)
end,
max_concurrency: System.schedulers_online() * 4,
max_concurrency: @standard_concurrency,
on_timeout: :kill_task,
timeout: :timer.seconds(5)
)
@@ -226,7 +245,7 @@ defmodule WandererApp.Character.TrackerPool do
WandererApp.Character.Tracker.check_offline(character_id)
end,
timeout: :timer.seconds(15),
max_concurrency: System.schedulers_online() * 4,
max_concurrency: @standard_concurrency,
on_timeout: :kill_task
)
|> Enum.each(fn
@@ -254,26 +273,52 @@ defmodule WandererApp.Character.TrackerPool do
) do
Process.send_after(self(), :update_location, @update_location_interval)
start_time = System.monotonic_time(:millisecond)
try do
characters
|> Task.async_stream(
fn character_id ->
WandererApp.Character.Tracker.update_location(character_id)
end,
max_concurrency: System.schedulers_online() * 4,
max_concurrency: location_concurrency(),
on_timeout: :kill_task,
timeout: :timer.seconds(5)
)
|> Enum.each(fn _result -> :ok end)
# Emit telemetry for location update performance
duration = System.monotonic_time(:millisecond) - start_time
:telemetry.execute(
[:wanderer_app, :tracker_pool, :location_update],
%{duration: duration, character_count: length(characters)},
%{pool_uuid: state.uuid}
)
# Warn if location updates are falling behind (taking > 800ms for 100 chars)
if duration > 800 do
Logger.warning(
"[Tracker Pool] Location updates falling behind: #{duration}ms for #{length(characters)} chars (pool: #{state.uuid})"
)
:telemetry.execute(
[:wanderer_app, :tracker_pool, :location_lag],
%{duration: duration, character_count: length(characters)},
%{pool_uuid: state.uuid}
)
end
{:noreply, %{state | last_location_duration: duration}}
rescue
e ->
Logger.error("""
[Tracker Pool] update_location => exception: #{Exception.message(e)}
#{Exception.format_stacktrace(__STACKTRACE__)}
""")
end
{:noreply, state}
{:noreply, state}
end
end
def handle_info(
@@ -289,32 +334,48 @@ defmodule WandererApp.Character.TrackerPool do
:update_ship,
%{
characters: characters,
server_online: true
server_online: true,
last_location_duration: location_duration
} =
state
) do
Process.send_after(self(), :update_ship, @update_ship_interval)
try do
characters
|> Task.async_stream(
fn character_id ->
WandererApp.Character.Tracker.update_ship(character_id)
end,
max_concurrency: System.schedulers_online() * 4,
on_timeout: :kill_task,
timeout: :timer.seconds(5)
# Backpressure: Skip ship updates if location updates are falling behind
if location_duration > 1000 do
Logger.debug(
"[Tracker Pool] Skipping ship update due to location lag (#{location_duration}ms)"
)
|> Enum.each(fn _result -> :ok end)
rescue
e ->
Logger.error("""
[Tracker Pool] update_ship => exception: #{Exception.message(e)}
#{Exception.format_stacktrace(__STACKTRACE__)}
""")
end
{:noreply, state}
:telemetry.execute(
[:wanderer_app, :tracker_pool, :ship_skipped],
%{count: 1},
%{pool_uuid: state.uuid, reason: :location_lag}
)
{:noreply, state}
else
try do
characters
|> Task.async_stream(
fn character_id ->
WandererApp.Character.Tracker.update_ship(character_id)
end,
max_concurrency: @standard_concurrency,
on_timeout: :kill_task,
timeout: :timer.seconds(5)
)
|> Enum.each(fn _result -> :ok end)
rescue
e ->
Logger.error("""
[Tracker Pool] update_ship => exception: #{Exception.message(e)}
#{Exception.format_stacktrace(__STACKTRACE__)}
""")
end
{:noreply, state}
end
end
def handle_info(
@@ -330,35 +391,51 @@ defmodule WandererApp.Character.TrackerPool do
:update_info,
%{
characters: characters,
server_online: true
server_online: true,
last_location_duration: location_duration
} =
state
) do
Process.send_after(self(), :update_info, @update_info_interval)
try do
characters
|> Task.async_stream(
fn character_id ->
WandererApp.Character.Tracker.update_info(character_id)
end,
timeout: :timer.seconds(15),
max_concurrency: System.schedulers_online() * 4,
on_timeout: :kill_task
# Backpressure: Skip info updates if location updates are severely falling behind
if location_duration > 1500 do
Logger.debug(
"[Tracker Pool] Skipping info update due to location lag (#{location_duration}ms)"
)
|> Enum.each(fn
{:ok, _result} -> :ok
error -> Logger.error("Error in update_info: #{inspect(error)}")
end)
rescue
e ->
Logger.error("""
[Tracker Pool] update_info => exception: #{Exception.message(e)}
#{Exception.format_stacktrace(__STACKTRACE__)}
""")
end
{:noreply, state}
:telemetry.execute(
[:wanderer_app, :tracker_pool, :info_skipped],
%{count: 1},
%{pool_uuid: state.uuid, reason: :location_lag}
)
{:noreply, state}
else
try do
characters
|> Task.async_stream(
fn character_id ->
WandererApp.Character.Tracker.update_info(character_id)
end,
timeout: :timer.seconds(15),
max_concurrency: @standard_concurrency,
on_timeout: :kill_task
)
|> Enum.each(fn
{:ok, _result} -> :ok
error -> Logger.error("Error in update_info: #{inspect(error)}")
end)
rescue
e ->
Logger.error("""
[Tracker Pool] update_info => exception: #{Exception.message(e)}
#{Exception.format_stacktrace(__STACKTRACE__)}
""")
end
{:noreply, state}
end
end
def handle_info(
@@ -387,7 +464,7 @@ defmodule WandererApp.Character.TrackerPool do
WandererApp.Character.Tracker.update_wallet(character_id)
end,
timeout: :timer.minutes(5),
max_concurrency: System.schedulers_online() * 4,
max_concurrency: @standard_concurrency,
on_timeout: :kill_task
)
|> Enum.each(fn

View File

@@ -2,6 +2,8 @@ defmodule WandererApp.Esi do
@moduledoc group: :esi
defdelegate get_server_status, to: WandererApp.Esi.ApiClient
defdelegate get_group_info(group_id, opts \\ []), to: WandererApp.Esi.ApiClient
defdelegate get_type_info(type_id, opts \\ []), to: WandererApp.Esi.ApiClient
defdelegate get_alliance_info(eve_id, opts \\ []), to: WandererApp.Esi.ApiClient
defdelegate get_corporation_info(eve_id, opts \\ []), to: WandererApp.Esi.ApiClient
defdelegate get_character_info(eve_id, opts \\ []), to: WandererApp.Esi.ApiClient

View File

@@ -17,6 +17,17 @@ defmodule WandererApp.Esi.ApiClient do
@logger Application.compile_env(:wanderer_app, :logger)
# Pool selection for different operation types
# Character tracking operations use dedicated high-capacity pool
@character_tracking_pool WandererApp.Finch.ESI.CharacterTracking
# General ESI operations use standard pool
@general_pool WandererApp.Finch.ESI.General
# Helper function to get Req options with appropriate Finch pool
defp req_options_for_pool(pool) do
[base_url: "https://esi.evetech.net", finch: pool]
end
def get_server_status, do: do_get("/status", [], @cache_opts)
def set_autopilot_waypoint(add_to_beginning, clear_other_waypoints, destination_id, opts \\ []),
@@ -38,10 +49,13 @@ defmodule WandererApp.Esi.ApiClient do
do:
do_post_esi(
"/characters/affiliation/",
json: character_eve_ids,
params: %{
datasource: "tranquility"
}
[
json: character_eve_ids,
params: %{
datasource: "tranquility"
}
],
@character_tracking_pool
)
def get_routes_custom(hubs, origin, params),
@@ -116,7 +130,33 @@ defmodule WandererApp.Esi.ApiClient do
@decorate cacheable(
cache: Cache,
key: "info-#{eve_id}",
key: "group-info-#{group_id}",
opts: [ttl: @ttl]
)
def get_group_info(group_id, opts),
do:
do_get(
"/universe/groups/#{group_id}/",
opts,
@cache_opts
)
@decorate cacheable(
cache: Cache,
key: "type-info-#{type_id}",
opts: [ttl: @ttl]
)
def get_type_info(type_id, opts),
do:
do_get(
"/universe/types/#{type_id}/",
opts,
@cache_opts
)
@decorate cacheable(
cache: Cache,
key: "alliance-info-#{eve_id}",
opts: [ttl: @ttl]
)
def get_alliance_info(eve_id, opts \\ []) do
@@ -137,7 +177,7 @@ defmodule WandererApp.Esi.ApiClient do
@decorate cacheable(
cache: Cache,
key: "info-#{eve_id}",
key: "corporation-info-#{eve_id}",
opts: [ttl: @ttl]
)
def get_corporation_info(eve_id, opts \\ []) do
@@ -150,7 +190,7 @@ defmodule WandererApp.Esi.ApiClient do
@decorate cacheable(
cache: Cache,
key: "info-#{eve_id}",
key: "character-info-#{eve_id}",
opts: [ttl: @ttl]
)
def get_character_info(eve_id, opts \\ []) do
@@ -206,9 +246,7 @@ defmodule WandererApp.Esi.ApiClient do
params = Keyword.get(opts, :params, %{}) |> Map.new()
search_val =
to_string(
Map.get(params, :search) || Map.get(params, "search") || ""
)
to_string(Map.get(params, :search) || Map.get(params, "search") || "")
categories_val =
to_string(
@@ -265,14 +303,18 @@ defmodule WandererApp.Esi.ApiClient do
character_id = opts |> Keyword.get(:character_id, nil)
# Use character tracking pool for character operations
pool = @character_tracking_pool
if not is_access_token_expired?(character_id) do
do_get(
path,
auth_opts,
opts |> with_refresh_token()
opts |> with_refresh_token(),
pool
)
else
do_get_retry(path, auth_opts, opts |> with_refresh_token())
do_get_retry(path, auth_opts, opts |> with_refresh_token(), :forbidden, pool)
end
end
@@ -306,19 +348,19 @@ defmodule WandererApp.Esi.ApiClient do
defp with_cache_opts(opts),
do: opts |> Keyword.merge(@cache_opts) |> Keyword.merge(cache_dir: System.tmp_dir!())
defp do_get(path, api_opts \\ [], opts \\ []) do
defp do_get(path, api_opts \\ [], opts \\ [], pool \\ @general_pool) do
case Cachex.get(:api_cache, path) do
{:ok, cached_data} when not is_nil(cached_data) ->
{:ok, cached_data}
_ ->
do_get_request(path, api_opts, opts)
do_get_request(path, api_opts, opts, pool)
end
end
defp do_get_request(path, api_opts \\ [], opts \\ []) do
defp do_get_request(path, api_opts \\ [], opts \\ [], pool \\ @general_pool) do
try do
@req_esi_options
req_options_for_pool(pool)
|> Req.new()
|> Req.get(
api_opts
@@ -409,12 +451,48 @@ defmodule WandererApp.Esi.ApiClient do
{:ok, %{status: status, headers: headers}} ->
{:error, "Unexpected status: #{status}"}
{:error, _reason} ->
{:error, %Mint.TransportError{reason: :timeout}} ->
# Emit telemetry for pool timeout
:telemetry.execute(
[:wanderer_app, :finch, :pool_timeout],
%{count: 1},
%{method: "GET", path: path, pool: pool}
)
{:error, :pool_timeout}
{:error, reason} ->
# Check if this is a Finch pool error
if is_exception(reason) and Exception.message(reason) =~ "unable to provide a connection" do
:telemetry.execute(
[:wanderer_app, :finch, :pool_exhausted],
%{count: 1},
%{method: "GET", path: path, pool: pool}
)
end
{:error, "Request failed"}
end
rescue
e ->
Logger.error(Exception.message(e))
error_msg = Exception.message(e)
# Emit telemetry for pool exhaustion errors
if error_msg =~ "unable to provide a connection" do
:telemetry.execute(
[:wanderer_app, :finch, :pool_exhausted],
%{count: 1},
%{method: "GET", path: path, pool: pool}
)
Logger.error("FINCH_POOL_EXHAUSTED: #{error_msg}",
method: "GET",
path: path,
pool: inspect(pool)
)
else
Logger.error(error_msg)
end
{:error, "Request failed"}
end
@@ -503,13 +581,13 @@ defmodule WandererApp.Esi.ApiClient do
end
end
defp do_post_esi(url, opts) do
defp do_post_esi(url, opts, pool \\ @general_pool) do
try do
req_opts =
(opts |> with_user_agent_opts() |> Keyword.merge(@retry_opts)) ++
[params: opts[:params] || []]
Req.new(@req_esi_options ++ req_opts)
Req.new(req_options_for_pool(pool) ++ req_opts)
|> Req.post(url: url)
|> case do
{:ok, %{status: status, body: body}} when status in [200, 201] ->
@@ -587,18 +665,54 @@ defmodule WandererApp.Esi.ApiClient do
{:ok, %{status: status}} ->
{:error, "Unexpected status: #{status}"}
{:error, %Mint.TransportError{reason: :timeout}} ->
# Emit telemetry for pool timeout
:telemetry.execute(
[:wanderer_app, :finch, :pool_timeout],
%{count: 1},
%{method: "POST_ESI", path: url, pool: pool}
)
{:error, :pool_timeout}
{:error, reason} ->
# Check if this is a Finch pool error
if is_exception(reason) and Exception.message(reason) =~ "unable to provide a connection" do
:telemetry.execute(
[:wanderer_app, :finch, :pool_exhausted],
%{count: 1},
%{method: "POST_ESI", path: url, pool: pool}
)
end
{:error, reason}
end
rescue
e ->
@logger.error(Exception.message(e))
error_msg = Exception.message(e)
# Emit telemetry for pool exhaustion errors
if error_msg =~ "unable to provide a connection" do
:telemetry.execute(
[:wanderer_app, :finch, :pool_exhausted],
%{count: 1},
%{method: "POST_ESI", path: url, pool: pool}
)
@logger.error("FINCH_POOL_EXHAUSTED: #{error_msg}",
method: "POST_ESI",
path: url,
pool: inspect(pool)
)
else
@logger.error(error_msg)
end
{:error, "Request failed"}
end
end
defp do_get_retry(path, api_opts, opts, status \\ :forbidden) do
defp do_get_retry(path, api_opts, opts, status \\ :forbidden, pool \\ @general_pool) do
refresh_token? = opts |> Keyword.get(:refresh_token?, false)
retry_count = opts |> Keyword.get(:retry_count, 0)
character_id = opts |> Keyword.get(:character_id, nil)
@@ -613,7 +727,8 @@ defmodule WandererApp.Esi.ApiClient do
do_get(
path,
api_opts |> Keyword.merge(auth_opts),
opts |> Keyword.merge(retry_count: retry_count + 1)
opts |> Keyword.merge(retry_count: retry_count + 1),
pool
)
{:error, _error} ->

View File

@@ -90,7 +90,9 @@ defmodule WandererApp.ExternalEvents.WebhookDispatcher do
@impl true
def handle_cast({:dispatch_events, map_id, events}, state) do
Logger.debug(fn -> "WebhookDispatcher received #{length(events)} events for map #{map_id}" end)
Logger.debug(fn ->
"WebhookDispatcher received #{length(events)} events for map #{map_id}"
end)
# Emit telemetry for batch events
:telemetry.execute(
@@ -290,7 +292,7 @@ defmodule WandererApp.ExternalEvents.WebhookDispatcher do
request = Finch.build(:post, url, headers, payload)
case Finch.request(request, WandererApp.Finch, timeout: 30_000) do
case Finch.request(request, WandererApp.Finch.Webhooks, timeout: 30_000) do
{:ok, %Finch.Response{status: status}} ->
{:ok, status}

View File

@@ -167,7 +167,9 @@ defmodule WandererApp.Map.Reconciler do
defp cleanup_zombie_maps([]), do: :ok
defp cleanup_zombie_maps(zombie_maps) do
Logger.warning("[Map Reconciler] Found #{length(zombie_maps)} zombie maps: #{inspect(zombie_maps)}")
Logger.warning(
"[Map Reconciler] Found #{length(zombie_maps)} zombie maps: #{inspect(zombie_maps)}"
)
Enum.each(zombie_maps, fn map_id ->
Logger.info("[Map Reconciler] Cleaning up zombie map: #{map_id}")
@@ -201,7 +203,9 @@ defmodule WandererApp.Map.Reconciler do
defp fix_orphan_maps([]), do: :ok
defp fix_orphan_maps(orphan_maps) do
Logger.warning("[Map Reconciler] Found #{length(orphan_maps)} orphan maps: #{inspect(orphan_maps)}")
Logger.warning(
"[Map Reconciler] Found #{length(orphan_maps)} orphan maps: #{inspect(orphan_maps)}"
)
Enum.each(orphan_maps, fn map_id ->
Logger.info("[Map Reconciler] Fixing orphan map: #{map_id}")
@@ -246,7 +250,10 @@ defmodule WandererApp.Map.Reconciler do
)
:error ->
Logger.warning("[Map Reconciler] Could not find pool for map #{map_id}, removing from cache")
Logger.warning(
"[Map Reconciler] Could not find pool for map #{map_id}, removing from cache"
)
Cachex.del(@cache, map_id)
end
end)

View File

@@ -90,7 +90,8 @@ defmodule WandererApp.Map.Operations.Signatures do
updated_signatures: [],
removed_signatures: [],
solar_system_id: solar_system_id,
character_id: validated_char_uuid, # Pass internal UUID here
# Pass internal UUID here
character_id: validated_char_uuid,
user_id: user_id,
delete_connection_with_sigs: false
}) do
@@ -176,7 +177,8 @@ defmodule WandererApp.Map.Operations.Signatures do
updated_signatures: [attrs],
removed_signatures: [],
solar_system_id: system.solar_system_id,
character_id: validated_char_uuid, # Pass internal UUID here
# Pass internal UUID here
character_id: validated_char_uuid,
user_id: user_id,
delete_connection_with_sigs: false
})

View File

@@ -34,28 +34,14 @@ defmodule WandererApp.Map.Server.CharactersImpl do
track_characters(map_id, rest)
end
def update_tracked_characters(map_id) do
def invalidate_characters(map_id) do
Task.start_link(fn ->
{:ok, all_map_tracked_character_ids} =
character_ids =
map_id
|> WandererApp.MapCharacterSettingsRepo.get_tracked_by_map_all()
|> case do
{:ok, settings} -> {:ok, settings |> Enum.map(&Map.get(&1, :character_id))}
_ -> {:ok, []}
end
|> WandererApp.Map.get_map!()
|> Map.get(:characters, [])
{:ok, actual_map_tracked_characters} =
WandererApp.Cache.lookup("maps:#{map_id}:tracked_characters", [])
characters_to_remove = actual_map_tracked_characters -- all_map_tracked_character_ids
WandererApp.Cache.insert_or_update(
"map_#{map_id}:invalidate_character_ids",
characters_to_remove,
fn ids ->
(ids ++ characters_to_remove) |> Enum.uniq()
end
)
WandererApp.Cache.insert("map_#{map_id}:invalidate_character_ids", character_ids)
:ok
end)

View File

@@ -223,6 +223,7 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
update_connection(map_id, :update_time_status, [:time_status], connection_update, fn
%{time_status: old_time_status},
%{id: connection_id, time_status: time_status} = updated_connection ->
# Handle EOL marking cache separately
case time_status == @connection_time_status_eol do
true ->
if old_time_status != @connection_time_status_eol do
@@ -230,18 +231,30 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
"map_#{map_id}:conn_#{connection_id}:mark_eol_time",
DateTime.utc_now()
)
set_start_time(map_id, connection_id, DateTime.utc_now())
end
_ ->
if old_time_status == @connection_time_status_eol do
WandererApp.Cache.delete("map_#{map_id}:conn_#{connection_id}:mark_eol_time")
set_start_time(map_id, connection_id, DateTime.utc_now())
end
end
# Always reset start_time when status changes (manual override)
# This ensures user manual changes aren't immediately overridden by cleanup
if time_status != old_time_status do
# Emit telemetry for manual time status change
:telemetry.execute(
[:wanderer_app, :connection, :manual_status_change],
%{system_time: System.system_time()},
%{
map_id: map_id,
connection_id: connection_id,
old_time_status: old_time_status,
new_time_status: time_status
}
)
set_start_time(map_id, connection_id, DateTime.utc_now())
maybe_update_linked_signature_time_status(map_id, updated_connection)
end
end)
@@ -353,6 +366,25 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
solar_system_source_id,
solar_system_target_id
) do
# Emit telemetry for automatic time status downgrade
elapsed_minutes = DateTime.diff(DateTime.utc_now(), connection_start_time, :minute)
:telemetry.execute(
[:wanderer_app, :connection, :auto_downgrade],
%{
elapsed_minutes: elapsed_minutes,
system_time: System.system_time()
},
%{
map_id: map_id,
connection_id: connection_id,
old_time_status: time_status,
new_time_status: new_time_status,
solar_system_source: solar_system_source_id,
solar_system_target: solar_system_target_id
}
)
set_start_time(map_id, connection_id, DateTime.utc_now())
update_connection_time_status(map_id, %{

View File

@@ -29,7 +29,7 @@ defmodule WandererApp.Map.Server.Impl do
@update_presence_timeout :timer.seconds(5)
@update_characters_timeout :timer.seconds(1)
@update_tracked_characters_timeout :timer.minutes(1)
@invalidate_characters_timeout :timer.hours(1)
def new(), do: __struct__()
def new(args), do: __struct__(args)
@@ -149,8 +149,8 @@ defmodule WandererApp.Map.Server.Impl do
Process.send_after(
self(),
{:update_tracked_characters, map_id},
@update_tracked_characters_timeout
{:invalidate_characters, map_id},
@invalidate_characters_timeout
)
Process.send_after(self(), {:update_presence, map_id}, @update_presence_timeout)
@@ -302,14 +302,14 @@ defmodule WandererApp.Map.Server.Impl do
CharactersImpl.update_characters(map_id)
end
def handle_event({:update_tracked_characters, map_id} = event) do
def handle_event({:invalidate_characters, map_id} = event) do
Process.send_after(
self(),
event,
@update_tracked_characters_timeout
@invalidate_characters_timeout
)
CharactersImpl.update_tracked_characters(map_id)
CharactersImpl.invalidate_characters(map_id)
end
def handle_event({:update_presence, map_id} = event) do

View File

@@ -5,7 +5,8 @@ defmodule WandererApp.Test.DDRT do
"""
@callback init_tree(String.t(), map()) :: :ok | {:error, term()}
@callback insert({integer(), any()} | list({integer(), any()}), String.t()) :: {:ok, map()} | {:error, term()}
@callback insert({integer(), any()} | list({integer(), any()}), String.t()) ::
{:ok, map()} | {:error, term()}
@callback update(integer(), any(), String.t()) :: {:ok, map()} | {:error, term()}
@callback delete(integer() | [integer()], String.t()) :: {:ok, map()} | {:error, term()}
@callback query(any(), String.t()) :: {:ok, [any()]} | {:error, term()}

View File

@@ -49,7 +49,7 @@ defmodule WandererApp.Ueberauth.Strategy.Eve do
WandererApp.Cache.put(
"eve_auth_#{params[:state]}",
[with_wallet: with_wallet, is_admin?: is_admin?],
ttl: :timer.minutes(15)
ttl: :timer.minutes(30)
)
opts = oauth_client_options_from_conn(conn, with_wallet, is_admin?)
@@ -66,17 +66,22 @@ defmodule WandererApp.Ueberauth.Strategy.Eve do
Handles the callback from Eve.
"""
def handle_callback!(%Plug.Conn{params: %{"code" => code, "state" => state}} = conn) do
opts =
WandererApp.Cache.get("eve_auth_#{state}")
case WandererApp.Cache.get("eve_auth_#{state}") do
nil ->
# Cache expired or invalid state - redirect to welcome page
conn
|> redirect!("/welcome")
params = [code: code]
opts ->
params = [code: code]
case WandererApp.Ueberauth.Strategy.Eve.OAuth.get_access_token(params, opts) do
{:ok, token} ->
fetch_user(conn, token)
case WandererApp.Ueberauth.Strategy.Eve.OAuth.get_access_token(params, opts) do
{:ok, token} ->
fetch_user(conn, token)
{:error, {error_code, error_description}} ->
set_errors!(conn, [error(error_code, error_description)])
{:error, {error_code, error_description}} ->
set_errors!(conn, [error(error_code, error_description)])
end
end
end

View File

@@ -34,7 +34,12 @@
</div>
</div>
<div class="navbar-center">
<a href="/" class="!opacity-0 text-[24px] text-white [text-shadow:0_0px_8px_rgba(0,0,0,0.8)]">Wanderer</a>
<a
href="/"
class="!opacity-0 text-[24px] text-white [text-shadow:0_0px_8px_rgba(0,0,0,0.8)]"
>
Wanderer
</a>
</div>
<div class="navbar-end"></div>
</navbar>
@@ -44,10 +49,13 @@
<!--Footer-->
<footer class="!z-10 w-full pt-8 pb-4 text-sm text-center fade-in flex justify-center items-center">
<div class="flex flex-col justify-center items-center">
<a target="_blank" rel="noopener noreferrer" href="https://www.eveonline.com/partners"><img src="/images/eo_pp.png" style="width: 300px;" alt="Eve Online Partnership Program"></a>
<a target="_blank" rel="noopener noreferrer" href="https://www.eveonline.com/partners">
<img src="/images/eo_pp.png" style="width: 300px;" alt="Eve Online Partnership Program" />
</a>
<div class="text-stone-400 no-underline hover:no-underline [text-shadow:0_0px_4px_rgba(0,0,0,0.8)]">
All <a href="/license">EVE related materials</a> are property of <a href="https://www.ccpgames.com">CCP Games</a>
&copy; {Date.utc_today().year} Wanderer Industries.
All <a href="/license">EVE related materials</a>
are property of <a href="https://www.ccpgames.com">CCP Games</a>
&copy; {Date.utc_today().year} Wanderer Industries.
</div>
</div>
</footer>

View File

@@ -34,5 +34,4 @@
<.new_version_banner app_version={@app_version} enabled={@map_subscriptions_enabled?} />
</div>
<.live_component module={WandererAppWeb.Alerts} id="notifications" view_flash={@flash} />

View File

@@ -42,8 +42,12 @@
</div>
<div class="absolute w-full bottom-2 p-4">
<% [first_part, second_part] = String.split(post.title, ":", parts: 2) %>
<h3 class="!m-0 !text-s font-bold break-normal ccp-font whitespace-nowrap text-white">{first_part}</h3>
<p class="!m-0 !text-s text-white text-ellipsis overflow-hidden whitespace-nowrap ccp-font">{second_part || ""}</p>
<h3 class="!m-0 !text-s font-bold break-normal ccp-font whitespace-nowrap text-white">
{first_part}
</h3>
<p class="!m-0 !text-s text-white text-ellipsis overflow-hidden whitespace-nowrap ccp-font">
{second_part || ""}
</p>
</div>
</div>
</.link>

View File

@@ -98,7 +98,10 @@
</div>
<div class="w-full justify-end">
<ul class="flex flex-wrap items-center p-0 m-0">
<li :for={tag <- @post.tags} class="inline-flex rounded-[35px] bg-primary px-1 text-white">
<li
:for={tag <- @post.tags}
class="inline-flex rounded-[35px] bg-primary px-1 text-white"
>
<a href="#">
<div class="badge badge-outline text-lime-400 rounded-none border-none text-xl">
#{tag}

View File

@@ -15,24 +15,63 @@ defmodule WandererAppWeb.MapSystemSignatureAPIController do
description: "A cosmic signature scanned in an EVE Online solar system",
type: :object,
properties: %{
id: %OpenApiSpex.Schema{type: :string, format: :uuid, description: "Unique signature identifier"},
solar_system_id: %OpenApiSpex.Schema{type: :integer, description: "EVE Online solar system ID"},
eve_id: %OpenApiSpex.Schema{type: :string, description: "In-game signature ID (e.g., ABC-123)"},
id: %OpenApiSpex.Schema{
type: :string,
format: :uuid,
description: "Unique signature identifier"
},
solar_system_id: %OpenApiSpex.Schema{
type: :integer,
description: "EVE Online solar system ID"
},
eve_id: %OpenApiSpex.Schema{
type: :string,
description: "In-game signature ID (e.g., ABC-123)"
},
character_eve_id: %OpenApiSpex.Schema{
type: :string,
description: "EVE character ID who scanned/updated this signature. Must be a valid character in the database. If not provided, defaults to the map owner's character.",
description:
"EVE character ID who scanned/updated this signature. Must be a valid character in the database. If not provided, defaults to the map owner's character.",
nullable: true
},
name: %OpenApiSpex.Schema{type: :string, nullable: true, description: "Signature name"},
description: %OpenApiSpex.Schema{type: :string, nullable: true, description: "Additional notes"},
description: %OpenApiSpex.Schema{
type: :string,
nullable: true,
description: "Additional notes"
},
type: %OpenApiSpex.Schema{type: :string, nullable: true, description: "Signature type"},
linked_system_id: %OpenApiSpex.Schema{type: :integer, nullable: true, description: "Connected solar system ID for wormholes"},
kind: %OpenApiSpex.Schema{type: :string, nullable: true, description: "Signature kind (e.g., cosmic_signature)"},
group: %OpenApiSpex.Schema{type: :string, nullable: true, description: "Signature group (e.g., wormhole, data, relic)"},
custom_info: %OpenApiSpex.Schema{type: :string, nullable: true, description: "Custom metadata"},
linked_system_id: %OpenApiSpex.Schema{
type: :integer,
nullable: true,
description: "Connected solar system ID for wormholes"
},
kind: %OpenApiSpex.Schema{
type: :string,
nullable: true,
description: "Signature kind (e.g., cosmic_signature)"
},
group: %OpenApiSpex.Schema{
type: :string,
nullable: true,
description: "Signature group (e.g., wormhole, data, relic)"
},
custom_info: %OpenApiSpex.Schema{
type: :string,
nullable: true,
description: "Custom metadata"
},
updated: %OpenApiSpex.Schema{type: :integer, nullable: true, description: "Update counter"},
inserted_at: %OpenApiSpex.Schema{type: :string, format: :date_time, description: "Creation timestamp"},
updated_at: %OpenApiSpex.Schema{type: :string, format: :date_time, description: "Last update timestamp"}
inserted_at: %OpenApiSpex.Schema{
type: :string,
format: :date_time,
description: "Creation timestamp"
},
updated_at: %OpenApiSpex.Schema{
type: :string,
format: :date_time,
description: "Last update timestamp"
}
},
required: [
:id,
@@ -178,7 +217,8 @@ defmodule WandererAppWeb.MapSystemSignatureAPIController do
properties: %{
error: %OpenApiSpex.Schema{
type: :string,
description: "Error type (e.g., 'invalid_character', 'system_not_found', 'missing_params')"
description:
"Error type (e.g., 'invalid_character', 'system_not_found', 'missing_params')"
}
},
example: %{error: "invalid_character"}

View File

@@ -186,14 +186,18 @@ defmodule WandererAppWeb.Plugs.CheckJsonApiAuth do
defp get_map_identifier(conn) do
# 1. Check path params (e.g., /api/v1/maps/:map_identifier/systems)
case conn.params["map_identifier"] do
id when is_binary(id) and id != "" -> id
id when is_binary(id) and id != "" ->
id
_ ->
# 2. Check request body for map_id (JSON:API format)
case conn.body_params do
%{"data" => %{"attributes" => %{"map_id" => map_id}}} when is_binary(map_id) and map_id != "" ->
%{"data" => %{"attributes" => %{"map_id" => map_id}}}
when is_binary(map_id) and map_id != "" ->
map_id
%{"data" => %{"relationships" => %{"map" => %{"data" => %{"id" => map_id}}}}} when is_binary(map_id) and map_id != "" ->
%{"data" => %{"relationships" => %{"map" => %{"data" => %{"id" => map_id}}}}}
when is_binary(map_id) and map_id != "" ->
map_id
# 3. Check flat body params (non-JSON:API format)

View File

@@ -336,7 +336,7 @@
label="Valid"
options={Enum.map(@valid_types, fn valid_type -> {valid_type.label, valid_type.id} end)}
/>
<!-- API Key Section with grid layout -->
<div class="modal-action">
<.button class="mt-2" type="submit" phx-disable-with="Saving...">

View File

@@ -148,13 +148,13 @@ defmodule WandererAppWeb.MapConnectionsEventHandler do
end
end
WandererApp.User.ActivityTracker.track_map_event(:map_connection_removed, %{
character_id: main_character_id,
user_id: current_user_id,
map_id: map_id,
solar_system_source_id: solar_system_source_id,
solar_system_target_id: solar_system_target_id
})
WandererApp.User.ActivityTracker.track_map_event(:map_connection_removed, %{
character_id: main_character_id,
user_id: current_user_id,
map_id: map_id,
solar_system_source_id: solar_system_source_id,
solar_system_target_id: solar_system_target_id
})
{:noreply, socket}
end
@@ -200,15 +200,15 @@ defmodule WandererAppWeb.MapConnectionsEventHandler do
_ -> nil
end
WandererApp.User.ActivityTracker.track_map_event(:map_connection_updated, %{
character_id: main_character_id,
user_id: current_user_id,
map_id: map_id,
solar_system_source_id: "#{solar_system_source_id}" |> String.to_integer(),
solar_system_target_id: "#{solar_system_target_id}" |> String.to_integer(),
key: key_atom,
value: value
})
WandererApp.User.ActivityTracker.track_map_event(:map_connection_updated, %{
character_id: main_character_id,
user_id: current_user_id,
map_id: map_id,
solar_system_source_id: "#{solar_system_source_id}" |> String.to_integer(),
solar_system_target_id: "#{solar_system_target_id}" |> String.to_integer(),
key: key_atom,
value: value
})
apply(WandererApp.Map.Server, method_atom, [
map_id,

View File

@@ -78,7 +78,36 @@ defmodule WandererAppWeb.Telemetry do
summary("vm.memory.total", unit: {:byte, :kilobyte}),
summary("vm.total_run_queue_lengths.total"),
summary("vm.total_run_queue_lengths.cpu"),
summary("vm.total_run_queue_lengths.io")
summary("vm.total_run_queue_lengths.io"),
# Finch Pool Metrics
counter("wanderer_app.finch.pool_exhausted.count",
tags: [:pool, :method],
description: "Count of Finch pool exhaustion errors"
),
counter("wanderer_app.finch.pool_timeout.count",
tags: [:pool, :method],
description: "Count of Finch pool timeout errors"
),
# Character Tracker Pool Metrics
summary("wanderer_app.tracker_pool.location_update.duration",
unit: :millisecond,
tags: [:pool_uuid],
description: "Time taken to update all character locations in a pool"
),
counter("wanderer_app.tracker_pool.location_lag.count",
tags: [:pool_uuid],
description: "Count of location updates falling behind (>800ms)"
),
counter("wanderer_app.tracker_pool.ship_skipped.count",
tags: [:pool_uuid, :reason],
description: "Count of ship updates skipped due to backpressure"
),
counter("wanderer_app.tracker_pool.info_skipped.count",
tags: [:pool_uuid, :reason],
description: "Count of info updates skipped due to backpressure"
)
]
end

View File

@@ -3,7 +3,7 @@ defmodule WandererApp.MixProject do
@source_url "https://github.com/wanderer-industries/wanderer"
@version "1.84.36"
@version "1.85.3"
def project do
[

View File

@@ -133,7 +133,12 @@ defmodule WandererApp.Repo.Migrations.FixDuplicateMapSlugs do
{:ok, %{rows: [[false]]}} ->
IO.puts("Creating unique index on slug...")
create_if_not_exists index(:maps_v1, [:slug], unique: true, name: :maps_v1_unique_slug_index)
create_if_not_exists index(:maps_v1, [:slug],
unique: true,
name: :maps_v1_unique_slug_index
)
IO.puts("✓ Index created successfully!")
{:error, error} ->

View File

@@ -64,6 +64,7 @@ defmodule WandererApp.Repo.Migrations.EnsureNoDuplicateMapSlugs do
case repo().query(duplicates_query, []) do
{:ok, %{rows: [[count]]}} ->
count
{:error, error} ->
IO.puts("Error counting duplicates: #{inspect(error)}")
0
@@ -157,6 +158,7 @@ defmodule WandererApp.Repo.Migrations.EnsureNoDuplicateMapSlugs do
case repo().query!(check_query, [candidate]) do
%{rows: [[0]]} ->
candidate
%{rows: [[_count]]} ->
# Try next number
generate_unique_slug(base_slug, n + 1)

View File

@@ -82,6 +82,7 @@ defmodule WandererApp.Repo.Migrations.UpdateMapSlugIndexIncludeDeleted do
case repo().query(duplicates_query, []) do
{:ok, %{rows: [[count]]}} ->
count
{:error, error} ->
IO.puts("Error counting duplicates: #{inspect(error)}")
0
@@ -160,6 +161,7 @@ defmodule WandererApp.Repo.Migrations.UpdateMapSlugIndexIncludeDeleted do
case repo().query!(check_query, [candidate]) do
%{rows: [[0]]} ->
candidate
%{rows: [[_count]]} ->
# Try next number
generate_unique_slug(base_slug, n + 1)

View File

@@ -19,10 +19,11 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
use WandererApp.DataCase, async: false
import WandererApp.MapTestHelpers
alias WandererApp.Map.Server.CharactersImpl
alias WandererApp.Map.Server.SystemsImpl
@test_map_id 999_999_001
@test_character_eve_id 2_123_456_789
# EVE Online solar system IDs for testing
@@ -32,8 +33,11 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
@system_rens 30_002_510
setup do
# Clean up any existing test data
cleanup_test_data()
# Setup system static info cache for test systems
setup_system_static_info_cache()
# Setup DDRT (R-tree) mock stubs for system positioning
setup_ddrt_mocks()
# Create test user (let Ash generate the ID)
user = create_user(%{name: "Test User", hash: "test_hash_#{:rand.uniform(1_000_000)}"})
@@ -48,150 +52,45 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
})
# Create test map
# Note: scope: :all is used because :none prevents system addition
# (is_connection_valid returns false for :none scope)
map = create_map(%{
id: @test_map_id,
name: "Test Char Track",
slug: "test-char-tracking-#{:rand.uniform(1_000_000)}",
owner_id: character.id,
scope: :none,
scope: :all,
only_tracked_characters: false
})
on_exit(fn ->
cleanup_test_data()
cleanup_test_data(map.id)
end)
{:ok, user: user, character: character, map: map}
end
defp cleanup_test_data do
# Note: We can't clean up character-specific caches in setup
# because we don't have the character.id yet. Tests will clean
# up their own caches in on_exit if needed.
# Clean up map-level presence tracking
WandererApp.Cache.delete("map_#{@test_map_id}:presence_character_ids")
end
defp cleanup_character_caches(character_id) do
# Clean up character location caches
WandererApp.Cache.delete("map_#{@test_map_id}:character:#{character_id}:solar_system_id")
WandererApp.Cache.delete("map_#{@test_map_id}:character:#{character_id}:start_solar_system_id")
WandererApp.Cache.delete("map_#{@test_map_id}:character:#{character_id}:station_id")
WandererApp.Cache.delete("map_#{@test_map_id}:character:#{character_id}:structure_id")
# Clean up character cache
if Cachex.exists?(:character_cache, character_id) do
Cachex.del(:character_cache, character_id)
end
# Clean up character state cache
if Cachex.exists?(:character_state_cache, character_id) do
Cachex.del(:character_state_cache, character_id)
end
end
defp set_character_location(character_id, solar_system_id, opts \\ []) do
"""
Helper to simulate character location update in cache.
This mimics what the Character.Tracker does when it polls ESI.
"""
structure_id = opts[:structure_id]
station_id = opts[:station_id]
ship_type_id = opts[:ship_type_id] || 670 # Capsule
# First get the existing character from cache or database to maintain all fields
{:ok, existing_character} = WandererApp.Character.get_character(character_id)
# Update character cache (mimics Character.update_character/2)
character_data = Map.merge(existing_character, %{
solar_system_id: solar_system_id,
structure_id: structure_id,
station_id: station_id,
ship_type_id: ship_type_id,
updated_at: DateTime.utc_now()
})
Cachex.put(:character_cache, character_id, character_data)
end
defp ensure_map_started(map_id) do
"""
Ensure the map server is started for the given map.
This is required for character updates to work.
"""
case WandererApp.Map.Manager.start_map(map_id) do
{:ok, _pid} -> :ok
{:error, {:already_started, _pid}} -> :ok
other -> other
end
end
defp add_character_to_map_presence(map_id, character_id) do
"""
Helper to add character to map's presence list.
This mimics what PresenceGracePeriodManager does.
"""
{:ok, current_chars} = WandererApp.Cache.lookup("map_#{map_id}:presence_character_ids", [])
updated_chars = Enum.uniq([character_id | current_chars])
WandererApp.Cache.insert("map_#{map_id}:presence_character_ids", updated_chars)
end
defp get_map_systems(map_id) do
"""
Helper to get all systems currently on the map.
"""
case WandererApp.Map.get_map_state(map_id) do
{:ok, %{map: %{systems: systems}}} when is_map(systems) ->
Map.values(systems)
{:ok, _} ->
[]
end
end
defp system_on_map?(map_id, solar_system_id) do
"""
Check if a specific system is on the map.
"""
systems = get_map_systems(map_id)
Enum.any?(systems, fn sys -> sys.solar_system_id == solar_system_id end)
end
defp wait_for_system_on_map(map_id, solar_system_id, timeout \\ 2000) do
"""
Wait for a system to appear on the map (for async operations).
"""
deadline = System.monotonic_time(:millisecond) + timeout
Stream.repeatedly(fn ->
if system_on_map?(map_id, solar_system_id) do
{:ok, true}
else
if System.monotonic_time(:millisecond) < deadline do
Process.sleep(50)
:continue
else
{:error, :timeout}
end
end
end)
|> Enum.find(fn result -> result != :continue end)
|> case do
{:ok, true} -> true
{:error, :timeout} -> false
end
end
# Note: Helper functions moved to WandererApp.MapTestHelpers
# Functions available via import:
# - setup_ddrt_mocks/0
# - setup_system_static_info_cache/0
# - set_character_location/3
# - ensure_map_started/1
# - wait_for_map_started/2
# - add_character_to_map_presence/2
# - get_map_systems/1
# - system_on_map?/2
# - wait_for_system_on_map/3
# - cleanup_character_caches/2
# - cleanup_test_data/1
describe "Basic character location tracking" do
@tag :skip
@tag :integration
test "character location update adds system to map", %{map: map, character: character} do
# This test verifies the basic flow:
# 1. Character starts tracking on a map
# 2. Character location is updated in cache
# 1. Character starts tracking on a map at Jita
# 2. Character moves to Amarr
# 3. update_characters() is called
# 4. System is added to the map
# 4. Both systems are added to the map
# Setup: Ensure map is started
ensure_map_started(map.id)
@@ -199,24 +98,37 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
# Setup: Add character to presence
add_character_to_map_presence(map.id, character.id)
# Setup: Set character location
# Setup: Character starts at Jita
set_character_location(character.id, @system_jita)
# Setup: Set start_solar_system_id (this happens when tracking starts)
# Note: The start system is NOT added until the character moves
WandererApp.Cache.insert(
"map_#{map.id}:character:#{character.id}:start_solar_system_id",
"map:#{map.id}:character:#{character.id}:start_solar_system_id",
@system_jita
)
# Execute: Run character update
# Execute: First update - start system is intentionally NOT added yet
CharactersImpl.update_characters(map.id)
# Verify: Jita should be added to the map
# Verify: Jita should NOT be on map yet (design: start position not added)
refute system_on_map?(map.id, @system_jita),
"Start system should not be added until character moves"
# Character moves to Amarr
set_character_location(character.id, @system_amarr)
# Execute: Second update - should add both systems
CharactersImpl.update_characters(map.id)
# Verify: Both systems should now be on map
assert wait_for_system_on_map(map.id, @system_jita),
"Jita should have been added to map when character tracking started"
"Jita should be added after character moves"
assert wait_for_system_on_map(map.id, @system_amarr),
"Amarr should be added as the new location"
end
@tag :skip
@tag :integration
test "character movement from A to B adds both systems", %{map: map, character: character} do
# This test verifies:
@@ -224,6 +136,7 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
# 2. Character moves to system B
# 3. update_characters() processes the change
# 4. Both systems are on the map
# Note: The start system is NOT added until the character moves (design decision)
# Setup: Ensure map is started
ensure_map_started(map.id)
@@ -234,33 +147,34 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
# Setup: Character starts at Jita
set_character_location(character.id, @system_jita)
WandererApp.Cache.insert(
"map_#{map.id}:character:#{character.id}:start_solar_system_id",
"map:#{map.id}:character:#{character.id}:start_solar_system_id",
@system_jita
)
# First update - adds Jita
# First update - start system is intentionally NOT added yet
CharactersImpl.update_characters(map.id)
assert wait_for_system_on_map(map.id, @system_jita), "Jita should be on map initially"
refute system_on_map?(map.id, @system_jita),
"Start system should not be added until character moves"
# Character moves to Amarr
set_character_location(character.id, @system_amarr)
# Second update - should add Amarr
# Second update - should add both systems
CharactersImpl.update_characters(map.id)
# Verify: Both systems should be on map
assert wait_for_system_on_map(map.id, @system_jita), "Jita should still be on map"
assert wait_for_system_on_map(map.id, @system_amarr), "Amarr should have been added to map"
# Verify: Both systems should be on map after character moves
assert wait_for_system_on_map(map.id, @system_jita), "Jita should be added after character moves"
assert wait_for_system_on_map(map.id, @system_amarr), "Amarr should be added as the new location"
end
end
describe "Rapid character movement (Race Condition Tests)" do
@tag :skip
@tag :integration
test "rapid movement A→B→C adds all three systems", %{map: map, character: character} do
# This test verifies the critical race condition fix:
# When a character moves rapidly through multiple systems,
# all systems should be added to the map, not just the start and end.
# Note: Start system is NOT added until character moves (design decision)
ensure_map_started(map.id)
add_character_to_map_presence(map.id, character.id)
@@ -268,32 +182,37 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
# Character starts at Jita
set_character_location(character.id, @system_jita)
WandererApp.Cache.insert(
"map_#{map.id}:character:#{character.id}:start_solar_system_id",
"map:#{map.id}:character:#{character.id}:start_solar_system_id",
@system_jita
)
# First update - start system is intentionally NOT added yet
CharactersImpl.update_characters(map.id)
assert wait_for_system_on_map(map.id, @system_jita)
refute system_on_map?(map.id, @system_jita),
"Start system should not be added until character moves"
# Rapid jump to Amarr (intermediate system)
set_character_location(character.id, @system_amarr)
# Before update_characters can process, character jumps again to Dodixie
# This simulates the race condition
CharactersImpl.update_characters(map.id) # Should process Jita→Amarr
# Second update - should add both Jita (start) and Amarr (current)
CharactersImpl.update_characters(map.id)
# Character already at Dodixie before second update
# Verify both Jita and Amarr are now on map
assert wait_for_system_on_map(map.id, @system_jita), "Jita (start) should be on map after movement"
assert wait_for_system_on_map(map.id, @system_amarr), "Amarr should be on map"
# Rapid jump to Dodixie before next update cycle
set_character_location(character.id, @system_dodixie)
CharactersImpl.update_characters(map.id) # Should process Amarr→Dodixie
# Third update - should add Dodixie
CharactersImpl.update_characters(map.id)
# Verify: All three systems should be on map
assert wait_for_system_on_map(map.id, @system_jita), "Jita (start) should be on map"
assert wait_for_system_on_map(map.id, @system_amarr), "Amarr (intermediate) should be on map - this is the critical test"
assert wait_for_system_on_map(map.id, @system_jita), "Jita (start) should still be on map"
assert wait_for_system_on_map(map.id, @system_amarr), "Amarr (intermediate) should still be on map - this is the critical test"
assert wait_for_system_on_map(map.id, @system_dodixie), "Dodixie (end) should be on map"
end
@tag :skip
@tag :integration
test "concurrent location updates don't lose intermediate systems", %{
map: map,
@@ -308,7 +227,7 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
# Start at Jita
set_character_location(character.id, @system_jita)
WandererApp.Cache.insert(
"map_#{map.id}:character:#{character.id}:start_solar_system_id",
"map:#{map.id}:character:#{character.id}:start_solar_system_id",
@system_jita
)
@@ -336,7 +255,6 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
end
describe "start_solar_system_id persistence" do
@tag :skip
@tag :integration
test "start_solar_system_id persists through multiple updates", %{
map: map,
@@ -353,7 +271,7 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
# Set start_solar_system_id
WandererApp.Cache.insert(
"map_#{map.id}:character:#{character.id}:start_solar_system_id",
"map:#{map.id}:character:#{character.id}:start_solar_system_id",
@system_jita
)
@@ -363,7 +281,7 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
# Verify start_solar_system_id still exists after first update
{:ok, start_system} =
WandererApp.Cache.lookup(
"map_#{map.id}:character:#{character.id}:start_solar_system_id"
"map:#{map.id}:character:#{character.id}:start_solar_system_id"
)
assert start_system == @system_jita,
@@ -380,7 +298,6 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
assert wait_for_system_on_map(map.id, @system_amarr)
end
@tag :skip
@tag :integration
test "first system addition uses correct logic when start_solar_system_id exists", %{
map: map,
@@ -388,6 +305,7 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
} do
# This test verifies that the first system addition logic
# works correctly with start_solar_system_id
# Design: Start system is NOT added until character moves
ensure_map_started(map.id)
add_character_to_map_presence(map.id, character.id)
@@ -397,114 +315,265 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
# Set start_solar_system_id
WandererApp.Cache.insert(
"map_#{map.id}:character:#{character.id}:start_solar_system_id",
"map:#{map.id}:character:#{character.id}:start_solar_system_id",
@system_jita
)
# No old location in map cache (first time tracking)
# This triggers the special first-system-addition logic
# First update - character still at start position
CharactersImpl.update_characters(map.id)
# Verify Jita is added
# Verify Jita is NOT added yet (design: start position not added until movement)
refute system_on_map?(map.id, @system_jita),
"Start system should not be added until character moves"
# Character moves to Amarr
set_character_location(character.id, @system_amarr)
# Second update - should add both systems
CharactersImpl.update_characters(map.id)
# Verify both systems are added after movement
assert wait_for_system_on_map(map.id, @system_jita),
"First system should be added when character starts tracking"
"Jita should be added after character moves away"
assert wait_for_system_on_map(map.id, @system_amarr),
"Amarr should be added as the new location"
end
end
describe "Database failure handling" do
@tag :integration
test "database failure during system creation is logged and retried", %{
map: map,
character: character
} do
# This test verifies that database failures don't silently succeed
# and are properly retried
test "system addition failures emit telemetry events", %{map: map, character: character} do
# This test verifies that database failures emit proper telemetry events
# Current implementation logs errors and emits telemetry for failures
# (Retry logic not yet implemented)
# NOTE: This test would need to mock the database to simulate failures
# For now, we document the expected behavior
ensure_map_started(map.id)
add_character_to_map_presence(map.id, character.id)
# Expected behavior:
# 1. maybe_add_system encounters DB error
# 2. Error is logged with context
# 3. Operation is retried (3 attempts with backoff)
# 4. If all retries fail, error tuple is returned (not :ok)
# 5. Telemetry event is emitted for the failure
test_pid = self()
:ok
# Attach handler for system addition error events
:telemetry.attach(
"test-system-addition-error",
[:wanderer_app, :map, :system_addition, :error],
fn event, measurements, metadata, _config ->
send(test_pid, {:telemetry_event, event, measurements, metadata})
end,
nil
)
# Set character at Jita and set start location
set_character_location(character.id, @system_jita)
WandererApp.Cache.insert(
"map:#{map.id}:character:#{character.id}:start_solar_system_id",
@system_jita
)
# Trigger update which may encounter database issues
# In production, database failures would emit telemetry
CharactersImpl.update_characters(map.id)
# Note: In a real database failure scenario, we would receive the telemetry event
# For this test, we verify the mechanism works by checking if the map was started correctly
# and that character updates can complete without crashing
# Verify update_characters completed (returned :ok without crashing)
assert :ok == CharactersImpl.update_characters(map.id)
:telemetry.detach("test-system-addition-error")
end
@tag :integration
test "transient database errors succeed on retry", %{map: map, character: character} do
# This test verifies retry logic for transient failures
# Expected behavior:
# 1. First attempt fails with transient error (timeout, connection, etc.)
# 2. Retry succeeds
# 3. System is added successfully
# 4. Telemetry emitted for both failure and success
:ok
end
@tag :integration
test "permanent database errors don't break update_characters for other characters", %{
test "character update errors are logged but don't crash update_characters", %{
map: map,
character: character
} do
# This test verifies that a failure for one character
# doesn't prevent processing other characters
# This test verifies that errors in character processing are caught
# and logged without crashing the entire update_characters cycle
# Expected behavior:
# 1. Multiple characters being tracked
# 2. One character's update fails permanently
# 3. Other characters' updates succeed
# 4. Error is logged with character context
# 5. update_characters completes for all characters
ensure_map_started(map.id)
add_character_to_map_presence(map.id, character.id)
:ok
# Set up character location
set_character_location(character.id, @system_jita)
WandererApp.Cache.insert(
"map:#{map.id}:character:#{character.id}:start_solar_system_id",
@system_jita
)
# Run update_characters - should complete even if individual character updates fail
result = CharactersImpl.update_characters(map.id)
assert result == :ok
# Verify the function is resilient and can be called multiple times
result = CharactersImpl.update_characters(map.id)
assert result == :ok
end
@tag :integration
test "errors processing one character don't affect other characters", %{map: map} do
# This test verifies that update_characters processes characters independently
# using Task.async_stream, so one failure doesn't block others
ensure_map_started(map.id)
# Create a second character
user2 = create_user(%{name: "Test User 2", hash: "test_hash_#{:rand.uniform(1_000_000)}"})
character2 = create_character(%{
eve_id: "#{@test_character_eve_id + 1}",
name: "Test Character 2",
user_id: user2.id,
scopes: "esi-location.read_location.v1 esi-location.read_ship_type.v1",
tracking_pool: "default"
})
# Add both characters to map presence
add_character_to_map_presence(map.id, character2.id)
# Set locations for both characters
set_character_location(character2.id, @system_amarr)
WandererApp.Cache.insert(
"map:#{map.id}:character:#{character2.id}:start_solar_system_id",
@system_amarr
)
# Run update_characters - should process both characters independently
result = CharactersImpl.update_characters(map.id)
assert result == :ok
# Clean up character 2 caches
cleanup_character_caches(map.id, character2.id)
end
end
describe "Task timeout handling" do
@tag :integration
@tag :slow
test "character update timeout doesn't lose state permanently", %{
map: map,
character: character
} do
# This test verifies that timeouts during update_characters
# don't cause permanent state loss
test "update_characters is resilient to processing delays", %{map: map, character: character} do
# This test verifies that update_characters handles task processing
# without crashing, even when individual character updates might be slow
# (Current implementation: 15-second timeout per task with :kill_task)
# Note: Recovery ETS table not yet implemented
# Expected behavior:
# 1. Character update takes > 15 seconds (simulated slow DB)
# 2. Task times out and is killed
# 3. State is preserved in recovery ETS table
# 4. Next update_characters cycle recovers and processes the update
# 5. System is eventually added to map
# 6. Telemetry emitted for timeout and recovery
ensure_map_started(map.id)
add_character_to_map_presence(map.id, character.id)
:ok
# Set up character with location
set_character_location(character.id, @system_jita)
WandererApp.Cache.insert(
"map:#{map.id}:character:#{character.id}:start_solar_system_id",
@system_jita
)
# Run multiple update cycles to verify stability
# If there were timeout/recovery issues, this would fail
for _i <- 1..3 do
result = CharactersImpl.update_characters(map.id)
assert result == :ok
Process.sleep(100)
end
# Verify the map server is still functional
systems = get_map_systems(map.id)
assert is_list(systems)
end
@tag :integration
test "multiple concurrent timeouts don't corrupt cache", %{map: map, character: character} do
# This test verifies that multiple simultaneous timeouts
# don't cause cache corruption
test "concurrent character updates don't cause crashes", %{map: map} do
# This test verifies that processing multiple characters concurrently
# (using Task.async_stream) doesn't cause crashes or corruption
# Even if some tasks might timeout or fail
# Expected behavior:
# 1. Multiple characters timing out simultaneously
# 2. Each timeout is handled independently
# 3. No cache corruption or race conditions
# 4. All characters eventually recover
# 5. Telemetry tracks recovery health
ensure_map_started(map.id)
:ok
# Create multiple characters for concurrent processing
characters = for i <- 1..5 do
user = create_user(%{
name: "Test User #{i}",
hash: "test_hash_#{:rand.uniform(1_000_000)}"
})
character = create_character(%{
eve_id: "#{@test_character_eve_id + i}",
name: "Test Character #{i}",
user_id: user.id,
scopes: "esi-location.read_location.v1 esi-location.read_ship_type.v1",
tracking_pool: "default"
})
# Add character to presence and set location
add_character_to_map_presence(map.id, character.id)
solar_system_id = Enum.at([@system_jita, @system_amarr, @system_dodixie, @system_rens], rem(i, 4))
set_character_location(character.id, solar_system_id)
WandererApp.Cache.insert(
"map:#{map.id}:character:#{character.id}:start_solar_system_id",
solar_system_id
)
character
end
# Run update_characters - should handle all characters concurrently
result = CharactersImpl.update_characters(map.id)
assert result == :ok
# Run again to verify stability
result = CharactersImpl.update_characters(map.id)
assert result == :ok
# Clean up character caches
Enum.each(characters, fn char ->
cleanup_character_caches(map.id, char.id)
end)
end
@tag :integration
test "update_characters emits telemetry for error cases", %{map: map, character: character} do
# This test verifies that errors during update_characters
# emit proper telemetry events for monitoring
ensure_map_started(map.id)
add_character_to_map_presence(map.id, character.id)
test_pid = self()
# Attach handlers for update_characters telemetry
:telemetry.attach_many(
"test-update-characters-telemetry",
[
[:wanderer_app, :map, :update_characters, :start],
[:wanderer_app, :map, :update_characters, :complete],
[:wanderer_app, :map, :update_characters, :error]
],
fn event, measurements, metadata, _config ->
send(test_pid, {:telemetry_event, event, measurements, metadata})
end,
nil
)
# Set up character location
set_character_location(character.id, @system_jita)
# Trigger update_characters
CharactersImpl.update_characters(map.id)
# Should receive start and complete events (or error event if something failed)
assert_receive {:telemetry_event, [:wanderer_app, :map, :update_characters, :start], _, _}, 1000
# Should receive either complete or error event
receive do
{:telemetry_event, [:wanderer_app, :map, :update_characters, :complete], _, _} -> :ok
{:telemetry_event, [:wanderer_app, :map, :update_characters, :error], _, _} -> :ok
after
1000 -> flunk("Expected to receive complete or error telemetry event")
end
:telemetry.detach("test-update-characters-telemetry")
end
end
describe "Cache consistency" do
@tag :skip
@tag :integration
test "character cache and map cache stay in sync", %{map: map, character: character} do
# This test verifies that the three character location caches
@@ -521,7 +590,7 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
# Set location in character cache
set_character_location(character.id, @system_jita)
WandererApp.Cache.insert(
"map_#{map.id}:character:#{character.id}:start_solar_system_id",
"map:#{map.id}:character:#{character.id}:start_solar_system_id",
@system_jita
)
@@ -529,7 +598,7 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
# Verify map cache was updated
{:ok, map_cached_location} =
WandererApp.Cache.lookup("map_#{map.id}:character:#{character.id}:solar_system_id")
WandererApp.Cache.lookup("map:#{map.id}:character:#{character.id}:solar_system_id")
assert map_cached_location == @system_jita,
"Map-specific cache should match character cache"
@@ -541,7 +610,7 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
# Verify both caches updated
{:ok, character_data} = Cachex.get(:character_cache, character.id)
{:ok, map_cached_location} =
WandererApp.Cache.lookup("map_#{map.id}:character:#{character.id}:solar_system_id")
WandererApp.Cache.lookup("map:#{map.id}:character:#{character.id}:solar_system_id")
assert character_data.solar_system_id == @system_amarr
assert map_cached_location == @system_amarr,
@@ -550,7 +619,7 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
end
describe "Telemetry and observability" do
test "telemetry events are emitted for location updates", %{character: character} do
test "telemetry events are emitted for location updates", %{character: character, map: map} do
# This test verifies that telemetry is emitted for tracking debugging
test_pid = self()
@@ -574,7 +643,7 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
:telemetry.execute(
[:wanderer_app, :character, :location_update, :start],
%{system_time: System.system_time()},
%{character_id: character.id, map_id: @test_map_id}
%{character_id: character.id, map_id: map.id}
)
:telemetry.execute(
@@ -582,7 +651,7 @@ defmodule WandererApp.Map.CharacterLocationTrackingTest do
%{duration: 100, system_time: System.system_time()},
%{
character_id: character.id,
map_id: @test_map_id,
map_id: map.id,
from_system: @system_jita,
to_system: @system_amarr
}

View File

@@ -117,16 +117,18 @@ defmodule WandererApp.DataCase do
:ok
end
end)
end
@doc """
Grants database access to a process with comprehensive monitoring.
# Grant database access to MapPoolSupervisor and all its dynamically started children
case Process.whereis(WandererApp.Map.MapPoolSupervisor) do
pid when is_pid(pid) ->
# Grant access to the supervisor and its entire supervision tree
# This ensures dynamically started map servers get database access
owner_pid = Process.get(:sandbox_owner_pid) || self()
WandererApp.Test.DatabaseAccessManager.grant_supervision_tree_access(pid, owner_pid)
This function provides enhanced database access granting with monitoring
for child processes and automatic access granting.
"""
def allow_database_access(pid, owner_pid \\ self()) do
WandererApp.Test.DatabaseAccessManager.grant_database_access(pid, owner_pid)
_ ->
:ok
end
end
@doc """

View File

@@ -22,6 +22,9 @@ defmodule WandererApp.Test.IntegrationConfig do
# Ensure PubSub server is started for integration tests
ensure_pubsub_server()
# Ensure map supervisors are started for map-related integration tests
ensure_map_supervisors_started()
:ok
end
@@ -57,6 +60,42 @@ defmodule WandererApp.Test.IntegrationConfig do
end
end
@doc """
Ensures map supervisors are started for integration tests.
This starts both MapPoolSupervisor and Map.Manager which are
required for character location tracking and map management tests.
IMPORTANT: MapPoolSupervisor must be started BEFORE Map.Manager
because Map.Manager depends on the registries created by MapPoolSupervisor.
"""
def ensure_map_supervisors_started do
# Start MapPoolSupervisor FIRST if not running
# This supervisor creates the required registries (:map_pool_registry, :unique_map_pool_registry)
# and starts MapPoolDynamicSupervisor
case Process.whereis(WandererApp.Map.MapPoolSupervisor) do
nil ->
{:ok, _} = WandererApp.Map.MapPoolSupervisor.start_link([])
_ ->
:ok
end
# Give the supervisor a moment to fully initialize its children
Process.sleep(100)
# Start Map.Manager AFTER MapPoolSupervisor
case GenServer.whereis(WandererApp.Map.Manager) do
nil ->
{:ok, _} = WandererApp.Map.Manager.start_link([])
_ ->
:ok
end
:ok
end
@doc """
Cleans up integration test environment.
@@ -74,6 +113,8 @@ defmodule WandererApp.Test.IntegrationConfig do
end
# Note: PubSub cleanup is handled by Phoenix during test shutdown
# Note: Map supervisors are not cleaned up here as they may be shared
# across tests and should persist for the test session
:ok
end

View File

@@ -1,8 +1,13 @@
defmodule WandererApp.MapTestHelpers do
@moduledoc """
Shared helper functions for map-related tests.
Shared helper functions for map-related integration tests.
This module provides common functionality for testing map servers,
character location tracking, and system management.
"""
import Mox
@doc """
Helper function to expect a map server error response.
This function is used across multiple test files to handle
@@ -17,4 +22,411 @@ defmodule WandererApp.MapTestHelpers do
:ok
end
end
@doc """
Ensures the map is started for the given map ID.
Uses async Map.Manager.start_map and waits for completion.
## Parameters
- map_id: The ID of the map to start
## Examples
iex> ensure_map_started(map.id)
:ok
"""
def ensure_map_started(map_id) do
# Queue the map for starting (async)
:ok = WandererApp.Map.Manager.start_map(map_id)
# Wait for the map to actually start
wait_for_map_started(map_id)
end
@doc """
Waits for a map to finish starting by polling the cache.
## Parameters
- map_id: The ID of the map to wait for
- timeout: Maximum time to wait in milliseconds (default: 10000)
## Examples
iex> wait_for_map_started(map.id, 5000)
:ok
"""
def wait_for_map_started(map_id, timeout \\ 10_000) do
deadline = System.monotonic_time(:millisecond) + timeout
Stream.repeatedly(fn ->
# Check both the map_started flag and the started_maps list
map_started_flag =
case WandererApp.Cache.lookup("map_#{map_id}:started") do
{:ok, true} -> true
_ -> false
end
in_started_maps_list =
case WandererApp.Cache.lookup("started_maps", []) do
{:ok, started_maps} when is_list(started_maps) ->
Enum.member?(started_maps, map_id)
_ ->
false
end
cond do
# Map is fully started
map_started_flag and in_started_maps_list ->
{:ok, :started}
# Map is partially started (in one but not both) - keep waiting
map_started_flag or in_started_maps_list ->
if System.monotonic_time(:millisecond) < deadline do
Process.sleep(100)
:continue
else
{:error, :timeout}
end
# Map not started yet
true ->
if System.monotonic_time(:millisecond) < deadline do
Process.sleep(100)
:continue
else
{:error, :timeout}
end
end
end)
|> Enum.find(fn result -> result != :continue end)
|> case do
{:ok, :started} ->
# Give it a bit more time to fully initialize all subsystems
Process.sleep(200)
:ok
{:error, :timeout} ->
raise "Timeout waiting for map #{map_id} to start. Check Map.Manager is running."
end
end
@doc """
Sets up DDRT (R-tree spatial index) mock stubs.
This is required for system positioning on the map.
We stub all R-tree operations to allow systems to be placed anywhere.
## Examples
iex> setup_ddrt_mocks()
:ok
"""
def setup_ddrt_mocks do
Test.DDRTMock
|> stub(:init_tree, fn _name, _opts -> :ok end)
|> stub(:insert, fn _data, _tree_name -> {:ok, %{}} end)
|> stub(:update, fn _id, _data, _tree_name -> {:ok, %{}} end)
|> stub(:delete, fn _ids, _tree_name -> {:ok, %{}} end)
# query returns empty list to indicate no spatial conflicts (position is available)
|> stub(:query, fn _bbox, _tree_name -> {:ok, []} end)
:ok
end
@doc """
Populates the system static info cache with data for common test systems.
This is required for SystemsImpl.maybe_add_system to work properly,
as it needs to fetch system names and other metadata.
## Parameters
- systems: Map of solar_system_id => system_info (optional, uses defaults if not provided)
## Examples
iex> setup_system_static_info_cache()
:ok
"""
def setup_system_static_info_cache(systems \\ nil) do
test_systems = systems || default_test_systems()
Enum.each(test_systems, fn {solar_system_id, system_info} ->
Cachex.put(:system_static_info_cache, solar_system_id, system_info)
end)
:ok
end
@doc """
Returns default test system configurations for common EVE systems.
## Examples
iex> default_test_systems()
%{30_000_142 => %{...}}
"""
def default_test_systems do
%{
# Jita
30_000_142 => %{
solar_system_id: 30_000_142,
region_id: 10_000_002,
constellation_id: 20_000_020,
solar_system_name: "Jita",
solar_system_name_lc: "jita",
constellation_name: "Kimotoro",
region_name: "The Forge",
system_class: 0,
security: "0.9",
type_description: "High Security",
class_title: "High Sec",
is_shattered: false,
effect_name: nil,
effect_power: nil,
statics: [],
wandering: [],
triglavian_invasion_status: nil,
sun_type_id: 45041
},
# Amarr
30_002_187 => %{
solar_system_id: 30_002_187,
region_id: 10_000_043,
constellation_id: 20_000_304,
solar_system_name: "Amarr",
solar_system_name_lc: "amarr",
constellation_name: "Throne Worlds",
region_name: "Domain",
system_class: 0,
security: "1.0",
type_description: "High Security",
class_title: "High Sec",
is_shattered: false,
effect_name: nil,
effect_power: nil,
statics: [],
wandering: [],
triglavian_invasion_status: nil,
sun_type_id: 45041
},
# Dodixie
30_002_659 => %{
solar_system_id: 30_002_659,
region_id: 10_000_032,
constellation_id: 20_000_413,
solar_system_name: "Dodixie",
solar_system_name_lc: "dodixie",
constellation_name: "Sinq Laison",
region_name: "Sinq Laison",
system_class: 0,
security: "0.9",
type_description: "High Security",
class_title: "High Sec",
is_shattered: false,
effect_name: nil,
effect_power: nil,
statics: [],
wandering: [],
triglavian_invasion_status: nil,
sun_type_id: 45041
},
# Rens
30_002_510 => %{
solar_system_id: 30_002_510,
region_id: 10_000_030,
constellation_id: 20_000_387,
solar_system_name: "Rens",
solar_system_name_lc: "rens",
constellation_name: "Frarn",
region_name: "Heimatar",
system_class: 0,
security: "0.9",
type_description: "High Security",
class_title: "High Sec",
is_shattered: false,
effect_name: nil,
effect_power: nil,
statics: [],
wandering: [],
triglavian_invasion_status: nil,
sun_type_id: 45041
}
}
end
@doc """
Helper to simulate character location update in cache.
This mimics what the Character.Tracker does when it polls ESI.
## Parameters
- character_id: The character ID to update
- solar_system_id: The solar system ID where the character is located
- opts: Optional parameters (structure_id, station_id, ship)
## Examples
iex> set_character_location(character.id, 30_000_142, ship: 670)
:ok
"""
def set_character_location(character_id, solar_system_id, opts \\ []) do
structure_id = opts[:structure_id]
station_id = opts[:station_id]
ship = opts[:ship] || 670 # Capsule
# First get the existing character from cache or database to maintain all fields
{:ok, existing_character} = WandererApp.Character.get_character(character_id)
# Update character cache (mimics Character.update_character/2)
character_data =
Map.merge(existing_character, %{
solar_system_id: solar_system_id,
structure_id: structure_id,
station_id: station_id,
ship: ship,
updated_at: DateTime.utc_now()
})
Cachex.put(:character_cache, character_id, character_data)
end
@doc """
Helper to add character to map's presence list.
This mimics what PresenceGracePeriodManager does.
## Parameters
- map_id: The map ID
- character_id: The character ID to add
## Examples
iex> add_character_to_map_presence(map.id, character.id)
:ok
"""
def add_character_to_map_presence(map_id, character_id) do
{:ok, current_chars} = WandererApp.Cache.lookup("map_#{map_id}:presence_character_ids", [])
updated_chars = Enum.uniq([character_id | current_chars])
WandererApp.Cache.insert("map_#{map_id}:presence_character_ids", updated_chars)
end
@doc """
Helper to get all systems currently on the map.
Uses :map_cache instead of :map_state_cache because add_system/2 updates :map_cache.
## Parameters
- map_id: The map ID
## Returns
- List of systems on the map
## Examples
iex> get_map_systems(map.id)
[%{solar_system_id: 30_000_142, ...}, ...]
"""
def get_map_systems(map_id) do
case WandererApp.Map.get_map(map_id) do
{:ok, %{systems: systems}} when is_map(systems) ->
Map.values(systems)
{:ok, _} ->
[]
{:error, _} ->
[]
end
end
@doc """
Checks if a specific system is on the map.
## Parameters
- map_id: The map ID
- solar_system_id: The solar system ID to check
## Returns
- true if the system is on the map, false otherwise
## Examples
iex> system_on_map?(map.id, 30_000_142)
true
"""
def system_on_map?(map_id, solar_system_id) do
systems = get_map_systems(map_id)
Enum.any?(systems, fn sys -> sys.solar_system_id == solar_system_id end)
end
@doc """
Waits for a system to appear on the map (for async operations).
## Parameters
- map_id: The map ID
- solar_system_id: The solar system ID to wait for
- timeout: Maximum time to wait in milliseconds (default: 2000)
## Returns
- true if the system appears on the map, false if timeout
## Examples
iex> wait_for_system_on_map(map.id, 30_000_142, 5000)
true
"""
def wait_for_system_on_map(map_id, solar_system_id, timeout \\ 2000) do
deadline = System.monotonic_time(:millisecond) + timeout
Stream.repeatedly(fn ->
if system_on_map?(map_id, solar_system_id) do
{:ok, true}
else
if System.monotonic_time(:millisecond) < deadline do
Process.sleep(50)
:continue
else
{:error, :timeout}
end
end
end)
|> Enum.find(fn result -> result != :continue end)
|> case do
{:ok, true} -> true
{:error, :timeout} -> false
end
end
@doc """
Cleans up character location caches for a specific character and map.
## Parameters
- map_id: The map ID
- character_id: The character ID
## Examples
iex> cleanup_character_caches(map.id, character.id)
:ok
"""
def cleanup_character_caches(map_id, character_id) do
# Clean up character location caches
WandererApp.Cache.delete("map:#{map_id}:character:#{character_id}:solar_system_id")
WandererApp.Cache.delete("map:#{map_id}:character:#{character_id}:start_solar_system_id")
WandererApp.Cache.delete("map:#{map_id}:character:#{character_id}:station_id")
WandererApp.Cache.delete("map:#{map_id}:character:#{character_id}:structure_id")
# Clean up character cache
if Cachex.exists?(:character_cache, character_id) do
Cachex.del(:character_cache, character_id)
end
# Clean up character state cache
if Cachex.exists?(:character_state_cache, character_id) do
Cachex.del(:character_state_cache, character_id)
end
:ok
end
@doc """
Cleans up test data for a map.
## Parameters
- map_id: The map ID
## Examples
iex> cleanup_test_data(map.id)
:ok
"""
def cleanup_test_data(map_id) do
# Clean up map-level presence tracking
WandererApp.Cache.delete("map_#{map_id}:presence_character_ids")
:ok
end
end

View File

@@ -176,103 +176,19 @@ defmodule WandererApp.TestHelpers do
@doc """
Ensures a map server is started for testing.
This function has been simplified to use the standard map startup flow.
For integration tests, use WandererApp.MapTestHelpers.ensure_map_started/1 instead.
"""
def ensure_map_server_started(map_id) do
case WandererApp.Map.Server.map_pid(map_id) do
pid when is_pid(pid) ->
# Make sure existing server has database access
WandererApp.DataCase.allow_database_access(pid)
# Also allow database access for any spawned processes
allow_map_server_children_database_access(pid)
# Ensure global Mox mode is maintained
if Code.ensure_loaded?(Mox), do: Mox.set_mox_global()
:ok
# Ensure global Mox mode is maintained
if Code.ensure_loaded?(Mox), do: Mox.set_mox_global()
nil ->
# Ensure global Mox mode before starting map server
if Code.ensure_loaded?(Mox), do: Mox.set_mox_global()
# Start the map server directly for tests
{:ok, pid} = start_map_server_directly(map_id)
# Grant database access to the new map server process
WandererApp.DataCase.allow_database_access(pid)
# Allow database access for any spawned processes
allow_map_server_children_database_access(pid)
:ok
end
end
# Use the standard map startup flow through Map.Manager
:ok = WandererApp.Map.Manager.start_map(map_id)
defp start_map_server_directly(map_id) do
# Use the same approach as MapManager.start_map_server/1
case DynamicSupervisor.start_child(
{:via, PartitionSupervisor, {WandererApp.Map.DynamicSupervisors, self()}},
{WandererApp.Map.ServerSupervisor, map_id: map_id}
) do
{:ok, pid} ->
# Allow database access for the supervisor and its children
WandererApp.DataCase.allow_genserver_database_access(pid)
# Wait a bit for the map to fully initialize
:timer.sleep(500)
# Allow Mox access for the supervisor process if in test mode
WandererApp.Test.MockAllowance.setup_genserver_mocks(pid)
# Also get the actual map server pid and allow access
case WandererApp.Map.Server.map_pid(map_id) do
server_pid when is_pid(server_pid) ->
WandererApp.DataCase.allow_genserver_database_access(server_pid)
# Allow Mox access for the map server process if in test mode
WandererApp.Test.MockAllowance.setup_genserver_mocks(server_pid)
_ ->
:ok
end
{:ok, pid}
{:error, {:already_started, pid}} ->
WandererApp.DataCase.allow_database_access(pid)
{:ok, pid}
{:error, :max_children} ->
# If we hit max children, wait a bit and retry
:timer.sleep(100)
start_map_server_directly(map_id)
error ->
error
end
end
defp allow_map_server_children_database_access(map_server_pid) do
# Allow database access for all children processes
# This is important for MapEventRelay and other spawned processes
# Wait a bit for children to spawn
:timer.sleep(100)
# Get all linked processes
case Process.info(map_server_pid, :links) do
{:links, linked_pids} ->
Enum.each(linked_pids, fn linked_pid ->
if is_pid(linked_pid) and Process.alive?(linked_pid) do
WandererApp.DataCase.allow_database_access(linked_pid)
# Also check for their children
case Process.info(linked_pid, :links) do
{:links, sub_links} ->
Enum.each(sub_links, fn sub_pid ->
if is_pid(sub_pid) and Process.alive?(sub_pid) and sub_pid != map_server_pid do
WandererApp.DataCase.allow_database_access(sub_pid)
end
end)
_ ->
:ok
end
end
end)
_ ->
:ok
end
:ok
end
end

View File

@@ -38,51 +38,52 @@ defmodule WandererApp.Map.CacheRTreeTest do
describe "insert/2" do
test "inserts single leaf", %{tree_name: name} do
leaf = {30000142, [{100, 230}, {50, 84}]}
leaf = {30_000_142, [{100, 230}, {50, 84}]}
assert {:ok, %{}} = CacheRTree.insert(leaf, name)
# Verify insertion
{:ok, ids} = CacheRTree.query([{100, 230}, {50, 84}], name)
assert 30000142 in ids
assert 30_000_142 in ids
end
test "inserts multiple leaves", %{tree_name: name} do
leaves = [
{30000142, [{100, 230}, {50, 84}]},
{30000143, [{250, 380}, {100, 134}]},
{30000144, [{400, 530}, {50, 84}]}
{30_000_142, [{100, 230}, {50, 84}]},
{30_000_143, [{250, 380}, {100, 134}]},
{30_000_144, [{400, 530}, {50, 84}]}
]
assert {:ok, %{}} = CacheRTree.insert(leaves, name)
# Verify all insertions
{:ok, ids1} = CacheRTree.query([{100, 230}, {50, 84}], name)
assert 30000142 in ids1
assert 30_000_142 in ids1
{:ok, ids2} = CacheRTree.query([{250, 380}, {100, 134}], name)
assert 30000143 in ids2
assert 30_000_143 in ids2
{:ok, ids3} = CacheRTree.query([{400, 530}, {50, 84}], name)
assert 30000144 in ids3
assert 30_000_144 in ids3
end
test "handles duplicate ID by overwriting", %{tree_name: name} do
# Insert first time
CacheRTree.insert({30000142, [{100, 230}, {50, 84}]}, name)
CacheRTree.insert({30_000_142, [{100, 230}, {50, 84}]}, name)
# Insert same ID with different bounding box
CacheRTree.insert({30000142, [{200, 330}, {100, 134}]}, name)
CacheRTree.insert({30_000_142, [{200, 330}, {100, 134}]}, name)
# Should find in new location
{:ok, ids_new} = CacheRTree.query([{200, 330}, {100, 134}], name)
assert 30000142 in ids_new
assert 30_000_142 in ids_new
# Should NOT find in old location
{:ok, ids_old} = CacheRTree.query([{100, 230}, {50, 84}], name)
assert 30000142 not in ids_old
assert 30_000_142 not in ids_old
end
test "handles integer IDs", %{tree_name: name} do
leaf = {123456, [{0, 130}, {0, 34}]}
leaf = {123_456, [{0, 130}, {0, 34}]}
assert {:ok, %{}} = CacheRTree.insert(leaf, name)
end
@@ -97,8 +98,8 @@ defmodule WandererApp.Map.CacheRTreeTest do
describe "delete/2" do
test "deletes single leaf", %{tree_name: name} do
CacheRTree.insert({30000142, [{100, 230}, {50, 84}]}, name)
assert {:ok, %{}} = CacheRTree.delete([30000142], name)
CacheRTree.insert({30_000_142, [{100, 230}, {50, 84}]}, name)
assert {:ok, %{}} = CacheRTree.delete([30_000_142], name)
# Verify deletion
{:ok, ids} = CacheRTree.query([{100, 230}, {50, 84}], name)
@@ -107,14 +108,15 @@ defmodule WandererApp.Map.CacheRTreeTest do
test "deletes multiple leaves", %{tree_name: name} do
leaves = [
{30000142, [{100, 230}, {50, 84}]},
{30000143, [{250, 380}, {100, 134}]},
{30000144, [{400, 530}, {50, 84}]}
{30_000_142, [{100, 230}, {50, 84}]},
{30_000_143, [{250, 380}, {100, 134}]},
{30_000_144, [{400, 530}, {50, 84}]}
]
CacheRTree.insert(leaves, name)
# Delete two of them
assert {:ok, %{}} = CacheRTree.delete([30000142, 30000143], name)
assert {:ok, %{}} = CacheRTree.delete([30_000_142, 30_000_143], name)
# Verify deletions
{:ok, ids1} = CacheRTree.query([{100, 230}, {50, 84}], name)
@@ -125,7 +127,7 @@ defmodule WandererApp.Map.CacheRTreeTest do
# Third should still exist
{:ok, ids3} = CacheRTree.query([{400, 530}, {50, 84}], name)
assert 30000144 in ids3
assert 30_000_144 in ids3
end
test "handles non-existent ID gracefully", %{tree_name: name} do
@@ -134,39 +136,39 @@ defmodule WandererApp.Map.CacheRTreeTest do
end
test "handles deleting from empty tree", %{tree_name: name} do
assert {:ok, %{}} = CacheRTree.delete([30000142], name)
assert {:ok, %{}} = CacheRTree.delete([30_000_142], name)
end
end
describe "update/3" do
test "updates leaf with new bounding box", %{tree_name: name} do
CacheRTree.insert({30000142, [{100, 230}, {50, 84}]}, name)
CacheRTree.insert({30_000_142, [{100, 230}, {50, 84}]}, name)
# Update to new position
new_box = [{200, 330}, {100, 134}]
assert {:ok, %{}} = CacheRTree.update(30000142, new_box, name)
assert {:ok, %{}} = CacheRTree.update(30_000_142, new_box, name)
# Should find in new location
{:ok, ids_new} = CacheRTree.query(new_box, name)
assert 30000142 in ids_new
assert 30_000_142 in ids_new
# Should NOT find in old location
{:ok, ids_old} = CacheRTree.query([{100, 230}, {50, 84}], name)
assert 30000142 not in ids_old
assert 30_000_142 not in ids_old
end
test "updates leaf with old/new tuple", %{tree_name: name} do
old_box = [{100, 230}, {50, 84}]
new_box = [{200, 330}, {100, 134}]
CacheRTree.insert({30000142, old_box}, name)
CacheRTree.insert({30_000_142, old_box}, name)
# Update with tuple
assert {:ok, %{}} = CacheRTree.update(30000142, {old_box, new_box}, name)
assert {:ok, %{}} = CacheRTree.update(30_000_142, {old_box, new_box}, name)
# Should find in new location
{:ok, ids_new} = CacheRTree.query(new_box, name)
assert 30000142 in ids_new
assert 30_000_142 in ids_new
end
test "handles updating non-existent leaf", %{tree_name: name} do
@@ -196,23 +198,25 @@ defmodule WandererApp.Map.CacheRTreeTest do
test "finds intersecting leaves", %{tree_name: name} do
leaves = [
{30000142, [{100, 230}, {50, 84}]},
{30000143, [{250, 380}, {100, 134}]},
{30000144, [{400, 530}, {50, 84}]}
{30_000_142, [{100, 230}, {50, 84}]},
{30_000_143, [{250, 380}, {100, 134}]},
{30_000_144, [{400, 530}, {50, 84}]}
]
CacheRTree.insert(leaves, name)
# Query overlapping with first system
{:ok, ids} = CacheRTree.query([{150, 280}, {60, 94}], name)
assert 30000142 in ids
assert 30_000_142 in ids
assert length(ids) == 1
end
test "excludes non-intersecting leaves", %{tree_name: name} do
leaves = [
{30000142, [{100, 230}, {50, 84}]},
{30000143, [{250, 380}, {100, 134}]}
{30_000_142, [{100, 230}, {50, 84}]},
{30_000_143, [{250, 380}, {100, 134}]}
]
CacheRTree.insert(leaves, name)
# Query that doesn't intersect any leaf
@@ -223,46 +227,48 @@ defmodule WandererApp.Map.CacheRTreeTest do
test "handles overlapping bounding boxes", %{tree_name: name} do
# Insert overlapping systems
leaves = [
{30000142, [{100, 230}, {50, 84}]},
{30000143, [{150, 280}, {60, 94}]} # Overlaps with first
{30_000_142, [{100, 230}, {50, 84}]},
# Overlaps with first
{30_000_143, [{150, 280}, {60, 94}]}
]
CacheRTree.insert(leaves, name)
# Query that overlaps both
{:ok, ids} = CacheRTree.query([{175, 200}, {65, 80}], name)
assert 30000142 in ids
assert 30000143 in ids
assert 30_000_142 in ids
assert 30_000_143 in ids
assert length(ids) == 2
end
test "edge case: exact match", %{tree_name: name} do
box = [{100, 230}, {50, 84}]
CacheRTree.insert({30000142, box}, name)
CacheRTree.insert({30_000_142, box}, name)
{:ok, ids} = CacheRTree.query(box, name)
assert 30000142 in ids
assert 30_000_142 in ids
end
test "edge case: contained box", %{tree_name: name} do
# Insert larger box
CacheRTree.insert({30000142, [{100, 300}, {50, 150}]}, name)
CacheRTree.insert({30_000_142, [{100, 300}, {50, 150}]}, name)
# Query with smaller box inside
{:ok, ids} = CacheRTree.query([{150, 250}, {75, 100}], name)
assert 30000142 in ids
assert 30_000_142 in ids
end
test "edge case: containing box", %{tree_name: name} do
# Insert smaller box
CacheRTree.insert({30000142, [{150, 250}, {75, 100}]}, name)
CacheRTree.insert({30_000_142, [{150, 250}, {75, 100}]}, name)
# Query with larger box that contains it
{:ok, ids} = CacheRTree.query([{100, 300}, {50, 150}], name)
assert 30000142 in ids
assert 30_000_142 in ids
end
test "edge case: adjacent boxes don't intersect", %{tree_name: name} do
CacheRTree.insert({30000142, [{100, 230}, {50, 84}]}, name)
CacheRTree.insert({30_000_142, [{100, 230}, {50, 84}]}, name)
# Adjacent box (touching but not overlapping)
{:ok, ids} = CacheRTree.query([{230, 360}, {50, 84}], name)
@@ -271,13 +277,14 @@ defmodule WandererApp.Map.CacheRTreeTest do
test "handles negative coordinates", %{tree_name: name} do
leaves = [
{30000142, [{-200, -70}, {-100, -66}]},
{30000143, [{-50, 80}, {-25, 9}]}
{30_000_142, [{-200, -70}, {-100, -66}]},
{30_000_143, [{-50, 80}, {-25, 9}]}
]
CacheRTree.insert(leaves, name)
{:ok, ids} = CacheRTree.query([{-150, -100}, {-90, -70}], name)
assert 30000142 in ids
assert 30_000_142 in ids
end
end
@@ -285,40 +292,40 @@ defmodule WandererApp.Map.CacheRTreeTest do
test "correctly maps leaves to grid cells", %{tree_name: name} do
# System node is 130x34, grid is 150x150
# This should fit in one cell
leaf = {30000142, [{10, 140}, {10, 44}]}
leaf = {30_000_142, [{10, 140}, {10, 44}]}
CacheRTree.insert(leaf, name)
# Query should find it
{:ok, ids} = CacheRTree.query([{10, 140}, {10, 44}], name)
assert 30000142 in ids
assert 30_000_142 in ids
end
test "handles leaves spanning multiple cells", %{tree_name: name} do
# Large box spanning 4 grid cells (150x150 each)
large_box = [{0, 300}, {0, 300}]
CacheRTree.insert({30000142, large_box}, name)
CacheRTree.insert({30_000_142, large_box}, name)
# Should be queryable from any quadrant
{:ok, ids1} = CacheRTree.query([{50, 100}, {50, 100}], name)
assert 30000142 in ids1
assert 30_000_142 in ids1
{:ok, ids2} = CacheRTree.query([{200, 250}, {50, 100}], name)
assert 30000142 in ids2
assert 30_000_142 in ids2
{:ok, ids3} = CacheRTree.query([{50, 100}, {200, 250}], name)
assert 30000142 in ids3
assert 30_000_142 in ids3
{:ok, ids4} = CacheRTree.query([{200, 250}, {200, 250}], name)
assert 30000142 in ids4
assert 30_000_142 in ids4
end
test "maintains grid consistency on delete", %{tree_name: name} do
# Insert leaf spanning multiple cells
large_box = [{0, 300}, {0, 300}]
CacheRTree.insert({30000142, large_box}, name)
CacheRTree.insert({30_000_142, large_box}, name)
# Delete it
CacheRTree.delete([30000142], name)
CacheRTree.delete([30_000_142], name)
# Should not be found in any cell
{:ok, ids1} = CacheRTree.query([{50, 100}, {50, 100}], name)
@@ -331,56 +338,61 @@ defmodule WandererApp.Map.CacheRTreeTest do
test "grid handles boundary conditions", %{tree_name: name} do
# Boxes exactly on grid boundaries
leaves = [
{30000142, [{0, 130}, {0, 34}]}, # Cell (0,0)
{30000143, [{150, 280}, {0, 34}]}, # Cell (1,0)
{30000144, [{0, 130}, {150, 184}]} # Cell (0,1)
# Cell (0,0)
{30_000_142, [{0, 130}, {0, 34}]},
# Cell (1,0)
{30_000_143, [{150, 280}, {0, 34}]},
# Cell (0,1)
{30_000_144, [{0, 130}, {150, 184}]}
]
CacheRTree.insert(leaves, name)
# Each should be queryable
{:ok, ids1} = CacheRTree.query([{0, 130}, {0, 34}], name)
assert 30000142 in ids1
assert 30_000_142 in ids1
{:ok, ids2} = CacheRTree.query([{150, 280}, {0, 34}], name)
assert 30000143 in ids2
assert 30_000_143 in ids2
{:ok, ids3} = CacheRTree.query([{0, 130}, {150, 184}], name)
assert 30000144 in ids3
assert 30_000_144 in ids3
end
end
describe "integration" do
test "realistic map scenario with many systems", %{tree_name: name} do
# Simulate 100 systems in a typical map layout
systems = for i <- 1..100 do
x = rem(i, 10) * 200
y = div(i, 10) * 100
{30000000 + i, [{x, x + 130}, {y, y + 34}]}
end
systems =
for i <- 1..100 do
x = rem(i, 10) * 200
y = div(i, 10) * 100
{30_000_000 + i, [{x, x + 130}, {y, y + 34}]}
end
# Insert all systems
assert {:ok, %{}} = CacheRTree.insert(systems, name)
# Query for a specific position
{:ok, ids} = CacheRTree.query([{200, 330}, {100, 134}], name)
assert 30000012 in ids
assert 30_000_012 in ids
# Delete some systems
to_delete = Enum.map(1..10, & &1 + 30000000)
to_delete = Enum.map(1..10, &(&1 + 30_000_000))
assert {:ok, %{}} = CacheRTree.delete(to_delete, name)
# Update some systems
assert {:ok, %{}} = CacheRTree.update(30000050, [{1000, 1130}, {500, 534}], name)
assert {:ok, %{}} = CacheRTree.update(30_000_050, [{1000, 1130}, {500, 534}], name)
# Verify the update
{:ok, ids_updated} = CacheRTree.query([{1000, 1130}, {500, 534}], name)
assert 30000050 in ids_updated
assert 30_000_050 in ids_updated
end
test "handles rapid insert/delete cycles", %{tree_name: name} do
# Simulate dynamic map updates
for i <- 1..50 do
system_id = 30000000 + i
system_id = 30_000_000 + i
box = [{i * 10, i * 10 + 130}, {i * 5, i * 5 + 34}]
# Insert
@@ -402,7 +414,7 @@ defmodule WandererApp.Map.CacheRTreeTest do
test "stress test: position availability checking", %{tree_name: name} do
# Insert systems in a grid pattern
for x <- 0..9, y <- 0..9 do
system_id = x * 10 + y + 30000000
system_id = x * 10 + y + 30_000_000
box = [{x * 200, x * 200 + 130}, {y * 100, y * 100 + 34}]
CacheRTree.insert({system_id, box}, name)
end
@@ -421,7 +433,7 @@ defmodule WandererApp.Map.CacheRTreeTest do
describe "clear_tree/1" do
test "removes all tree data from cache", %{tree_name: name} do
# Insert some data
CacheRTree.insert({30000142, [{100, 230}, {50, 84}]}, name)
CacheRTree.insert({30_000_142, [{100, 230}, {50, 84}]}, name)
# Clear the tree
assert :ok = CacheRTree.clear_tree(name)

View File

@@ -513,14 +513,16 @@ defmodule WandererApp.Map.MapPoolCrashRecoveryTest do
map_ids = Enum.to_list(1..20)
# Measure save time
{save_time_us, :ok} = :timer.tc(fn ->
MapPoolState.save_pool_state(uuid, map_ids)
end)
{save_time_us, :ok} =
:timer.tc(fn ->
MapPoolState.save_pool_state(uuid, map_ids)
end)
# Measure retrieval time
{get_time_us, {:ok, _}} = :timer.tc(fn ->
MapPoolState.get_pool_state(uuid)
end)
{get_time_us, {:ok, _}} =
:timer.tc(fn ->
MapPoolState.get_pool_state(uuid)
end)
# Both operations should be very fast (< 1ms)
assert save_time_us < 1000, "Save took #{save_time_us}µs, expected < 1000µs"
@@ -543,9 +545,10 @@ defmodule WandererApp.Map.MapPoolCrashRecoveryTest do
end)
# Measure cleanup time
{cleanup_time_us, {:ok, deleted_count}} = :timer.tc(fn ->
MapPoolState.cleanup_stale_entries()
end)
{cleanup_time_us, {:ok, deleted_count}} =
:timer.tc(fn ->
MapPoolState.cleanup_stale_entries()
end)
# Should have deleted at least 100 entries
assert deleted_count >= 100

View File

@@ -50,7 +50,9 @@ defmodule WandererApp.Map.MapPoolTest do
end
describe "cache lookup with registry fallback" do
test "stop_map handles cache miss by scanning registry", %{registries_running: registries_running?} do
test "stop_map handles cache miss by scanning registry", %{
registries_running: registries_running?
} do
if registries_running? do
# Setup: Create a map_id that's not in cache but will be found in registry scan
map_id = "test_map_#{:rand.uniform(1_000_000)}"
@@ -66,7 +68,9 @@ defmodule WandererApp.Map.MapPoolTest do
end
end
test "stop_map handles non-existent pool_uuid in registry", %{registries_running: registries_running?} do
test "stop_map handles non-existent pool_uuid in registry", %{
registries_running: registries_running?
} do
if registries_running? do
map_id = "test_map_#{:rand.uniform(1_000_000)}"
fake_uuid = "fake_uuid_#{:rand.uniform(1_000_000)}"
@@ -81,7 +85,9 @@ defmodule WandererApp.Map.MapPoolTest do
end
end
test "stop_map updates cache when found via registry scan", %{registries_running: registries_running?} do
test "stop_map updates cache when found via registry scan", %{
registries_running: registries_running?
} do
if registries_running? do
# This test would require a running pool with registered maps
# For now, we verify the fallback logic doesn't crash
@@ -115,7 +121,9 @@ defmodule WandererApp.Map.MapPoolTest do
end
describe "Reconciler - zombie map detection and cleanup" do
test "reconciler detects zombie maps in started_maps cache", %{reconciler_running: reconciler_running?} do
test "reconciler detects zombie maps in started_maps cache", %{
reconciler_running: reconciler_running?
} do
if reconciler_running? do
# Setup: Add maps to started_maps that aren't in any registry
zombie_map_id = "zombie_map_#{:rand.uniform(1_000_000)}"
@@ -189,7 +197,9 @@ defmodule WandererApp.Map.MapPoolTest do
end
describe "Reconciler - cache inconsistency detection and fix" do
test "reconciler detects map with missing cache entry", %{reconciler_running: reconciler_running?} do
test "reconciler detects map with missing cache entry", %{
reconciler_running: reconciler_running?
} do
if reconciler_running? do
# This test verifies the reconciler can detect when a map
# is in the registry but has no cache entry
@@ -209,7 +219,9 @@ defmodule WandererApp.Map.MapPoolTest do
end
end
test "reconciler detects cache pointing to non-existent pool", %{reconciler_running: reconciler_running?} do
test "reconciler detects cache pointing to non-existent pool", %{
reconciler_running: reconciler_running?
} do
if reconciler_running? do
map_id = "test_map_#{:rand.uniform(1_000_000)}"
fake_uuid = "fake_uuid_#{:rand.uniform(1_000_000)}"
@@ -267,7 +279,9 @@ defmodule WandererApp.Map.MapPoolTest do
end
describe "Reconciler - manual trigger" do
test "trigger_reconciliation runs reconciliation immediately", %{reconciler_running: reconciler_running?} do
test "trigger_reconciliation runs reconciliation immediately", %{
reconciler_running: reconciler_running?
} do
if reconciler_running? do
zombie_map_id = "zombie_map_#{:rand.uniform(1_000_000)}"
@@ -307,7 +321,9 @@ defmodule WandererApp.Map.MapPoolTest do
end
end
test "reconciler handles empty registries gracefully", %{reconciler_running: reconciler_running?} do
test "reconciler handles empty registries gracefully", %{
reconciler_running: reconciler_running?
} do
if reconciler_running? do
# Clear everything
cleanup_test_data()

View File

@@ -58,11 +58,15 @@ defmodule WandererApp.Map.SlugRecoveryTest do
assert Ecto.UUID.load!(kept_id_binary) == map1_id
# Verify the other maps were renamed with numeric suffixes
{:ok, map2_result} = Repo.query("SELECT slug FROM maps_v1 WHERE id = $1", [Ecto.UUID.dump!(map2_id)])
{:ok, map2_result} =
Repo.query("SELECT slug FROM maps_v1 WHERE id = $1", [Ecto.UUID.dump!(map2_id)])
[[map2_slug]] = map2_result.rows
assert map2_slug == "duplicate-slug-2"
{:ok, map3_result} = Repo.query("SELECT slug FROM maps_v1 WHERE id = $1", [Ecto.UUID.dump!(map3_id)])
{:ok, map3_result} =
Repo.query("SELECT slug FROM maps_v1 WHERE id = $1", [Ecto.UUID.dump!(map3_id)])
[[map3_slug]] = map3_result.rows
assert map3_slug == "duplicate-slug-3"
@@ -115,7 +119,9 @@ defmodule WandererApp.Map.SlugRecoveryTest do
assert result.fixed_count == 1
# Map 3 should get "test-3" since "test-2" is already taken
{:ok, map3} = Repo.query("SELECT slug FROM maps_v1 WHERE id = $1", [Ecto.UUID.dump!(map3_id)])
{:ok, map3} =
Repo.query("SELECT slug FROM maps_v1 WHERE id = $1", [Ecto.UUID.dump!(map3_id)])
assert map3.rows == [["test-3"]]
# Recreate index after test
@@ -147,7 +153,9 @@ defmodule WandererApp.Map.SlugRecoveryTest do
assert stats.total_maps_renamed == 3
# Verify all duplicates are fixed
{:ok, result} = Repo.query("SELECT slug, COUNT(*) FROM maps_v1 GROUP BY slug HAVING COUNT(*) > 1")
{:ok, result} =
Repo.query("SELECT slug, COUNT(*) FROM maps_v1 GROUP BY slug HAVING COUNT(*) > 1")
assert result.rows == []
# Recreate index after test

View File

@@ -155,7 +155,9 @@ defmodule WandererApp.Map.SlugUniquenessTest do
# Verify all successful maps have unique slugs
slugs = Enum.map(maps, & &1.slug)
assert length(Enum.uniq(slugs)) == length(slugs), "All successful maps should have unique slugs"
assert length(Enum.uniq(slugs)) == length(slugs),
"All successful maps should have unique slugs"
# Log results for visibility
Logger.info("Concurrent test: #{length(successful)} succeeded, #{length(failed)} failed")
@@ -309,12 +311,12 @@ defmodule WandererApp.Map.SlugUniquenessTest do
defp create_test_user do
# Create a test user with necessary attributes
{:ok, user} =
WandererApp.Api.User.new(%{
name: "Test User #{:rand.uniform(10_000)}",
eve_id: :rand.uniform(100_000_000)
})
user
case Ash.create(WandererApp.Api.User, %{
name: "Test User #{:rand.uniform(10_000)}",
hash: "test_hash_#{:rand.uniform(100_000_000)}"
}) do
{:ok, user} -> user
{:error, reason} -> raise "Failed to create user: #{inspect(reason)}"
end
end
end

View File

@@ -20,11 +20,13 @@ defmodule WandererApp.User.ActivityTrackerTest do
# - Invalid data
# The key requirement is that it NEVER crashes the calling code
result = ActivityTracker.track_map_event(:map_connection_added, %{
character_id: nil, # This will cause the function to skip tracking
user_id: nil,
map_id: nil
})
result =
ActivityTracker.track_map_event(:map_connection_added, %{
# This will cause the function to skip tracking
character_id: nil,
user_id: nil,
map_id: nil
})
# Should return success even when input is incomplete
assert {:ok, _} = result
@@ -35,11 +37,12 @@ defmodule WandererApp.User.ActivityTrackerTest do
# This is important for monitoring and debugging
# The function should complete without raising even with incomplete data
assert {:ok, _} = ActivityTracker.track_map_event(:map_connection_added, %{
character_id: nil,
user_id: nil,
map_id: nil
})
assert {:ok, _} =
ActivityTracker.track_map_event(:map_connection_added, %{
character_id: nil,
user_id: nil,
map_id: nil
})
end
end
@@ -49,10 +52,11 @@ defmodule WandererApp.User.ActivityTrackerTest do
end
test "returns {:ok, nil} on error without crashing" do
result = ActivityTracker.track_acl_event(:map_acl_added, %{
user_id: nil,
acl_id: nil
})
result =
ActivityTracker.track_acl_event(:map_acl_added, %{
user_id: nil,
acl_id: nil
})
assert {:ok, _} = result
end
@@ -68,11 +72,12 @@ defmodule WandererApp.User.ActivityTrackerTest do
# regardless of internal errors
# Test with nil values (which will fail validation)
assert {:ok, _} = ActivityTracker.track_map_event(:test_event, %{
character_id: nil,
user_id: nil,
map_id: nil
})
assert {:ok, _} =
ActivityTracker.track_map_event(:test_event, %{
character_id: nil,
user_id: nil,
map_id: nil
})
# Test with empty map (which will fail validation)
assert {:ok, _} = ActivityTracker.track_map_event(:test_event, %{})