Instance communities retrieval
This commit is contained in:
parent
58848c934e
commit
25cae3c0e9
7 changed files with 1047 additions and 19 deletions
|
|
@ -5,11 +5,14 @@
|
|||
use Domains\Platform\Resources\PlatformChannelResource;
|
||||
use Domains\Platform\Models\PlatformChannel;
|
||||
use Domains\Platform\Models\PlatformAccount;
|
||||
use Domains\Platform\Models\PlatformInstance;
|
||||
use Domains\Platform\Services\ChannelLanguageDetectionService;
|
||||
use Domains\Platform\Api\Lemmy\LemmyApiService;
|
||||
use Domains\Platform\Exceptions\ChannelException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class PlatformChannelsController extends BaseController
|
||||
{
|
||||
|
|
@ -271,4 +274,108 @@ public function updateAccountRelation(PlatformChannel $channel, PlatformAccount
|
|||
return $this->sendError('Failed to update platform account relationship: ' . $e->getMessage(), [], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available communities for a platform instance
|
||||
*/
|
||||
public function getCommunities(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$validated = $request->validate([
|
||||
'platform_instance_id' => 'required|exists:platform_instances,id',
|
||||
'type' => 'sometimes|string|in:Local,All,Subscribed',
|
||||
'sort' => 'sometimes|string|in:Hot,Active,New,TopDay,TopWeek,TopMonth,TopYear,TopAll',
|
||||
'limit' => 'sometimes|integer|min:1|max:100',
|
||||
'page' => 'sometimes|integer|min:1',
|
||||
'show_nsfw' => 'sometimes|boolean',
|
||||
]);
|
||||
|
||||
$platformInstance = PlatformInstance::findOrFail($validated['platform_instance_id']);
|
||||
|
||||
// Check if there are active platform accounts for this instance to get auth token
|
||||
$activeAccount = PlatformAccount::where('instance_url', $platformInstance->url)
|
||||
->where('is_active', true)
|
||||
->first();
|
||||
|
||||
if (!$activeAccount) {
|
||||
return $this->sendError(
|
||||
'Cannot fetch communities: No active platform accounts found for this instance. Please create a platform account first.',
|
||||
[],
|
||||
422
|
||||
);
|
||||
}
|
||||
|
||||
// Create cache key based on instance and parameters
|
||||
$cacheKey = sprintf(
|
||||
'communities:%s:%s:%s:%d:%d:%s',
|
||||
$platformInstance->id,
|
||||
$validated['type'] ?? 'Local',
|
||||
$validated['sort'] ?? 'Active',
|
||||
$validated['limit'] ?? 50,
|
||||
$validated['page'] ?? 1,
|
||||
$validated['show_nsfw'] ?? false ? '1' : '0'
|
||||
);
|
||||
|
||||
// Try to get communities from cache first (cache for 10 minutes)
|
||||
$communities = Cache::remember($cacheKey, 600, function () use ($platformInstance, $activeAccount, $validated) {
|
||||
$apiService = app(LemmyApiService::class, ['instance' => $platformInstance->url]);
|
||||
|
||||
return $apiService->listCommunities(
|
||||
$activeAccount->settings['api_token'] ?? null,
|
||||
$validated['type'] ?? 'Local',
|
||||
$validated['sort'] ?? 'Active',
|
||||
$validated['limit'] ?? 50,
|
||||
$validated['page'] ?? 1,
|
||||
$validated['show_nsfw'] ?? false
|
||||
);
|
||||
});
|
||||
|
||||
// Transform the response to include only relevant data and add helpful fields
|
||||
$transformedCommunities = collect($communities['communities'] ?? [])->map(function ($item) {
|
||||
$community = $item['community'] ?? [];
|
||||
return [
|
||||
'id' => $community['id'] ?? null,
|
||||
'name' => $community['name'] ?? null,
|
||||
'title' => $community['title'] ?? null,
|
||||
'description' => $community['description'] ?? null,
|
||||
'nsfw' => $community['nsfw'] ?? false,
|
||||
'local' => $community['local'] ?? false,
|
||||
'subscribers' => $item['counts']['subscribers'] ?? 0,
|
||||
'posts' => $item['counts']['posts'] ?? 0,
|
||||
'display_text' => sprintf(
|
||||
'%s (%s subscribers)',
|
||||
$community['title'] ?? $community['name'] ?? 'Unknown',
|
||||
number_format($item['counts']['subscribers'] ?? 0)
|
||||
),
|
||||
];
|
||||
});
|
||||
|
||||
return $this->sendResponse([
|
||||
'communities' => $transformedCommunities,
|
||||
'total' => $transformedCommunities->count(),
|
||||
'platform_instance' => [
|
||||
'id' => $platformInstance->id,
|
||||
'name' => $platformInstance->name,
|
||||
'url' => $platformInstance->url,
|
||||
],
|
||||
'parameters' => [
|
||||
'type' => $validated['type'] ?? 'Local',
|
||||
'sort' => $validated['sort'] ?? 'Active',
|
||||
'limit' => $validated['limit'] ?? 50,
|
||||
'page' => $validated['page'] ?? 1,
|
||||
'show_nsfw' => $validated['show_nsfw'] ?? false,
|
||||
]
|
||||
], 'Communities retrieved successfully.');
|
||||
|
||||
} catch (ValidationException $e) {
|
||||
return $this->sendValidationError($e->errors());
|
||||
} catch (\Exception $e) {
|
||||
// Clear cache on error to prevent serving stale data
|
||||
if (isset($cacheKey)) {
|
||||
Cache::forget($cacheKey);
|
||||
}
|
||||
|
||||
return $this->sendError('Failed to fetch communities: ' . $e->getMessage(), [], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -57,6 +57,10 @@
|
|||
->name('api.platform-accounts.set-active');
|
||||
|
||||
// Platform Channels
|
||||
// NOTE: Specific routes must come before resource routes to avoid parameter conflicts
|
||||
Route::get('/platform-channels/communities', [PlatformChannelsController::class, 'getCommunities'])
|
||||
->name('api.platform-channels.get-communities');
|
||||
|
||||
Route::apiResource('platform-channels', PlatformChannelsController::class)->names([
|
||||
'index' => 'api.platform-channels.index',
|
||||
'store' => 'api.platform-channels.store',
|
||||
|
|
|
|||
|
|
@ -246,4 +246,70 @@ public function getLanguages(): array
|
|||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List communities on the instance with optional filtering
|
||||
*
|
||||
* @param string|null $token
|
||||
* @param string $type Local, All, or Subscribed
|
||||
* @param string $sort Hot, Active, New, TopDay, TopWeek, TopMonth, TopYear, TopAll
|
||||
* @param int $limit Maximum number of communities to return (default: 50)
|
||||
* @param int $page Page number for pagination (default: 1)
|
||||
* @param bool $showNsfw Whether to include NSFW communities (default: false)
|
||||
* @return array<string, mixed>
|
||||
* @throws Exception
|
||||
*/
|
||||
public function listCommunities(
|
||||
?string $token = null,
|
||||
string $type = 'Local',
|
||||
string $sort = 'Active',
|
||||
int $limit = 50,
|
||||
int $page = 1,
|
||||
bool $showNsfw = false
|
||||
): array {
|
||||
try {
|
||||
$request = new LemmyRequest($this->instance, $token);
|
||||
|
||||
$params = [
|
||||
'type_' => $type,
|
||||
'sort' => $sort,
|
||||
'limit' => $limit,
|
||||
'page' => $page,
|
||||
'show_nsfw' => $showNsfw,
|
||||
];
|
||||
|
||||
$response = $request->get('community/list', $params);
|
||||
|
||||
if (!$response->successful()) {
|
||||
$statusCode = $response->status();
|
||||
$responseBody = $response->body();
|
||||
|
||||
logger()->warning('Failed to fetch communities list', [
|
||||
'status' => $statusCode,
|
||||
'response' => $responseBody,
|
||||
'params' => $params
|
||||
]);
|
||||
|
||||
throw new Exception("Failed to fetch communities list: {$statusCode} - {$responseBody}");
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
if (!isset($data['communities'])) {
|
||||
logger()->warning('Invalid communities list response format', ['response' => $data]);
|
||||
return ['communities' => []];
|
||||
}
|
||||
|
||||
return $data;
|
||||
} catch (Exception $e) {
|
||||
logger()->error('Exception while fetching communities list', [
|
||||
'error' => $e->getMessage(),
|
||||
'type' => $type,
|
||||
'sort' => $sort,
|
||||
'limit' => $limit,
|
||||
'page' => $page
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@
|
|||
use Domains\Platform\Models\PlatformChannel;
|
||||
use Domains\Platform\Models\PlatformInstance;
|
||||
use Domains\Platform\Services\ChannelLanguageDetectionService;
|
||||
use Domains\Platform\Api\Lemmy\LemmyApiService;
|
||||
use Domains\Settings\Models\Language;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Tests\TestCase;
|
||||
use Mockery;
|
||||
|
||||
|
|
@ -270,4 +272,390 @@ public function test_toggle_deactivates_active_channel(): void
|
|||
'is_active' => false
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_get_communities_returns_successful_response(): void
|
||||
{
|
||||
// Arrange
|
||||
$instance = PlatformInstance::factory()->create(['url' => 'lemmy.world']);
|
||||
$account = PlatformAccount::factory()->create([
|
||||
'instance_url' => $instance->url,
|
||||
'is_active' => true,
|
||||
'settings' => ['api_token' => 'test-token']
|
||||
]);
|
||||
|
||||
// Mock LemmyApiService
|
||||
$mockApiService = Mockery::mock(LemmyApiService::class);
|
||||
$mockApiService->shouldReceive('listCommunities')
|
||||
->with('test-token', 'Local', 'Active', 50, 1, false)
|
||||
->once()
|
||||
->andReturn([
|
||||
'communities' => [
|
||||
[
|
||||
'community' => [
|
||||
'id' => 1,
|
||||
'name' => 'technology',
|
||||
'title' => 'Technology',
|
||||
'description' => 'Tech discussions',
|
||||
'nsfw' => false,
|
||||
'local' => true
|
||||
],
|
||||
'counts' => [
|
||||
'subscribers' => 1500,
|
||||
'posts' => 250
|
||||
]
|
||||
],
|
||||
[
|
||||
'community' => [
|
||||
'id' => 2,
|
||||
'name' => 'news',
|
||||
'title' => 'News',
|
||||
'description' => 'Latest news',
|
||||
'nsfw' => false,
|
||||
'local' => true
|
||||
],
|
||||
'counts' => [
|
||||
'subscribers' => 2300,
|
||||
'posts' => 450
|
||||
]
|
||||
]
|
||||
]
|
||||
]);
|
||||
|
||||
$this->app->bind(LemmyApiService::class, function () use ($mockApiService) {
|
||||
return $mockApiService;
|
||||
});
|
||||
|
||||
// Act
|
||||
$response = $this->getJson('/api/v1/platform-channels/communities?platform_instance_id=' . $instance->id);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(200)
|
||||
->assertJsonStructure([
|
||||
'success',
|
||||
'message',
|
||||
'data' => [
|
||||
'communities' => [
|
||||
'*' => [
|
||||
'id',
|
||||
'name',
|
||||
'title',
|
||||
'description',
|
||||
'nsfw',
|
||||
'local',
|
||||
'subscribers',
|
||||
'posts',
|
||||
'display_text'
|
||||
]
|
||||
],
|
||||
'total',
|
||||
'platform_instance' => [
|
||||
'id',
|
||||
'name',
|
||||
'url'
|
||||
],
|
||||
'parameters' => [
|
||||
'type',
|
||||
'sort',
|
||||
'limit',
|
||||
'page',
|
||||
'show_nsfw'
|
||||
]
|
||||
]
|
||||
])
|
||||
->assertJson([
|
||||
'success' => true,
|
||||
'message' => 'Communities retrieved successfully.',
|
||||
'data' => [
|
||||
'communities' => [
|
||||
[
|
||||
'id' => 1,
|
||||
'name' => 'technology',
|
||||
'title' => 'Technology',
|
||||
'subscribers' => 1500,
|
||||
'display_text' => 'Technology (1,500 subscribers)'
|
||||
],
|
||||
[
|
||||
'id' => 2,
|
||||
'name' => 'news',
|
||||
'title' => 'News',
|
||||
'subscribers' => 2300,
|
||||
'display_text' => 'News (2,300 subscribers)'
|
||||
]
|
||||
],
|
||||
'total' => 2,
|
||||
'parameters' => [
|
||||
'type' => 'Local',
|
||||
'sort' => 'Active',
|
||||
'limit' => 50,
|
||||
'page' => 1,
|
||||
'show_nsfw' => false
|
||||
]
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_get_communities_with_custom_parameters(): void
|
||||
{
|
||||
// Arrange
|
||||
$instance = PlatformInstance::factory()->create(['url' => 'lemmy.world']);
|
||||
$account = PlatformAccount::factory()->create([
|
||||
'instance_url' => $instance->url,
|
||||
'is_active' => true,
|
||||
'settings' => ['api_token' => 'test-token']
|
||||
]);
|
||||
|
||||
// Mock LemmyApiService with custom parameters
|
||||
$mockApiService = Mockery::mock(LemmyApiService::class);
|
||||
$mockApiService->shouldReceive('listCommunities')
|
||||
->with('test-token', 'All', 'TopMonth', 25, 2, true)
|
||||
->once()
|
||||
->andReturn(['communities' => []]);
|
||||
|
||||
$this->app->bind(LemmyApiService::class, function () use ($mockApiService) {
|
||||
return $mockApiService;
|
||||
});
|
||||
|
||||
// Act
|
||||
$response = $this->getJson('/api/v1/platform-channels/communities?' . http_build_query([
|
||||
'platform_instance_id' => $instance->id,
|
||||
'type' => 'All',
|
||||
'sort' => 'TopMonth',
|
||||
'limit' => 25,
|
||||
'page' => 2,
|
||||
'show_nsfw' => true
|
||||
]));
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(200)
|
||||
->assertJson([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'parameters' => [
|
||||
'type' => 'All',
|
||||
'sort' => 'TopMonth',
|
||||
'limit' => 25,
|
||||
'page' => 2,
|
||||
'show_nsfw' => true
|
||||
]
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_get_communities_validates_required_platform_instance_id(): void
|
||||
{
|
||||
$response = $this->getJson('/api/v1/platform-channels/communities');
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors(['platform_instance_id']);
|
||||
}
|
||||
|
||||
public function test_get_communities_validates_platform_instance_exists(): void
|
||||
{
|
||||
$response = $this->getJson('/api/v1/platform-channels/communities?platform_instance_id=999');
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors(['platform_instance_id']);
|
||||
}
|
||||
|
||||
public function test_get_communities_validates_type_parameter(): void
|
||||
{
|
||||
$instance = PlatformInstance::factory()->create();
|
||||
|
||||
$response = $this->getJson('/api/v1/platform-channels/communities?' . http_build_query([
|
||||
'platform_instance_id' => $instance->id,
|
||||
'type' => 'InvalidType'
|
||||
]));
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors(['type']);
|
||||
}
|
||||
|
||||
public function test_get_communities_validates_sort_parameter(): void
|
||||
{
|
||||
$instance = PlatformInstance::factory()->create();
|
||||
|
||||
$response = $this->getJson('/api/v1/platform-channels/communities?' . http_build_query([
|
||||
'platform_instance_id' => $instance->id,
|
||||
'sort' => 'InvalidSort'
|
||||
]));
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors(['sort']);
|
||||
}
|
||||
|
||||
public function test_get_communities_validates_limit_parameter(): void
|
||||
{
|
||||
$instance = PlatformInstance::factory()->create();
|
||||
|
||||
$response = $this->getJson('/api/v1/platform-channels/communities?' . http_build_query([
|
||||
'platform_instance_id' => $instance->id,
|
||||
'limit' => 0 // Invalid: below minimum
|
||||
]));
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors(['limit']);
|
||||
|
||||
$response = $this->getJson('/api/v1/platform-channels/communities?' . http_build_query([
|
||||
'platform_instance_id' => $instance->id,
|
||||
'limit' => 101 // Invalid: above maximum
|
||||
]));
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors(['limit']);
|
||||
}
|
||||
|
||||
public function test_get_communities_validates_page_parameter(): void
|
||||
{
|
||||
$instance = PlatformInstance::factory()->create();
|
||||
|
||||
$response = $this->getJson('/api/v1/platform-channels/communities?' . http_build_query([
|
||||
'platform_instance_id' => $instance->id,
|
||||
'page' => 0 // Invalid: below minimum
|
||||
]));
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors(['page']);
|
||||
}
|
||||
|
||||
public function test_get_communities_fails_when_no_active_account_exists(): void
|
||||
{
|
||||
$instance = PlatformInstance::factory()->create(['url' => 'lemmy.world']);
|
||||
|
||||
// Create an inactive account
|
||||
PlatformAccount::factory()->create([
|
||||
'instance_url' => $instance->url,
|
||||
'is_active' => false
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/v1/platform-channels/communities?platform_instance_id=' . $instance->id);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJson([
|
||||
'success' => false,
|
||||
'message' => 'Cannot fetch communities: No active platform accounts found for this instance. Please create a platform account first.'
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_get_communities_uses_cache(): void
|
||||
{
|
||||
// Arrange
|
||||
$instance = PlatformInstance::factory()->create(['url' => 'lemmy.world']);
|
||||
$account = PlatformAccount::factory()->create([
|
||||
'instance_url' => $instance->url,
|
||||
'is_active' => true,
|
||||
'settings' => ['api_token' => 'test-token']
|
||||
]);
|
||||
|
||||
$mockData = ['communities' => [['community' => ['id' => 1, 'name' => 'test']]]];
|
||||
$cacheKey = "communities:{$instance->id}:Local:Active:50:1:0";
|
||||
|
||||
// Set cache data
|
||||
Cache::put($cacheKey, $mockData, 600);
|
||||
|
||||
// Mock should not be called since cache hit
|
||||
$mockApiService = Mockery::mock(LemmyApiService::class);
|
||||
$mockApiService->shouldNotReceive('listCommunities');
|
||||
|
||||
$this->app->bind(LemmyApiService::class, function () use ($mockApiService) {
|
||||
return $mockApiService;
|
||||
});
|
||||
|
||||
// Act
|
||||
$response = $this->getJson('/api/v1/platform-channels/communities?platform_instance_id=' . $instance->id);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(200)
|
||||
->assertJson(['success' => true]);
|
||||
}
|
||||
|
||||
public function test_get_communities_clears_cache_on_error(): void
|
||||
{
|
||||
// Arrange
|
||||
$instance = PlatformInstance::factory()->create(['url' => 'lemmy.world']);
|
||||
$account = PlatformAccount::factory()->create([
|
||||
'instance_url' => $instance->url,
|
||||
'is_active' => true,
|
||||
'settings' => ['api_token' => 'test-token']
|
||||
]);
|
||||
|
||||
$cacheKey = "communities:{$instance->id}:Local:Active:50:1:0";
|
||||
|
||||
// Mock LemmyApiService to throw exception
|
||||
$mockApiService = Mockery::mock(LemmyApiService::class);
|
||||
$mockApiService->shouldReceive('listCommunities')
|
||||
->once()
|
||||
->andThrow(new \Exception('API Error'));
|
||||
|
||||
$this->app->bind(LemmyApiService::class, function () use ($mockApiService) {
|
||||
return $mockApiService;
|
||||
});
|
||||
|
||||
// Ensure cache is empty initially so API service gets called
|
||||
Cache::forget($cacheKey);
|
||||
$this->assertFalse(Cache::has($cacheKey));
|
||||
|
||||
// Act
|
||||
$response = $this->getJson('/api/v1/platform-channels/communities?platform_instance_id=' . $instance->id);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(500);
|
||||
// Cache should still not exist since the error prevented caching
|
||||
$this->assertFalse(Cache::has($cacheKey));
|
||||
}
|
||||
|
||||
public function test_get_communities_handles_missing_community_data_gracefully(): void
|
||||
{
|
||||
// Arrange
|
||||
$instance = PlatformInstance::factory()->create(['url' => 'lemmy.world']);
|
||||
$account = PlatformAccount::factory()->create([
|
||||
'instance_url' => $instance->url,
|
||||
'is_active' => true,
|
||||
'settings' => ['api_token' => 'test-token']
|
||||
]);
|
||||
|
||||
// Mock LemmyApiService with incomplete community data
|
||||
$mockApiService = Mockery::mock(LemmyApiService::class);
|
||||
$mockApiService->shouldReceive('listCommunities')
|
||||
->once()
|
||||
->andReturn([
|
||||
'communities' => [
|
||||
[
|
||||
'community' => [
|
||||
'id' => 1,
|
||||
'name' => 'minimal',
|
||||
// Missing title, description, etc.
|
||||
],
|
||||
// Missing counts
|
||||
]
|
||||
]
|
||||
]);
|
||||
|
||||
$this->app->bind(LemmyApiService::class, function () use ($mockApiService) {
|
||||
return $mockApiService;
|
||||
});
|
||||
|
||||
// Act
|
||||
$response = $this->getJson('/api/v1/platform-channels/communities?platform_instance_id=' . $instance->id);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(200)
|
||||
->assertJson([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'communities' => [
|
||||
[
|
||||
'id' => 1,
|
||||
'name' => 'minimal',
|
||||
'title' => null,
|
||||
'description' => null,
|
||||
'nsfw' => false,
|
||||
'local' => false,
|
||||
'subscribers' => 0,
|
||||
'posts' => 0,
|
||||
'display_text' => 'minimal (0 subscribers)'
|
||||
]
|
||||
]
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -426,4 +426,260 @@ public function test_get_languages_returns_empty_when_all_languages_missing(): v
|
|||
|
||||
$this->assertEquals([], $languages);
|
||||
}
|
||||
|
||||
public function test_list_communities_success_with_default_parameters(): void
|
||||
{
|
||||
Http::fake([
|
||||
'*' => Http::response([
|
||||
'communities' => [
|
||||
[
|
||||
'community' => [
|
||||
'id' => 1,
|
||||
'name' => 'technology',
|
||||
'title' => 'Technology Community',
|
||||
'description' => 'All about tech',
|
||||
'nsfw' => false
|
||||
]
|
||||
],
|
||||
[
|
||||
'community' => [
|
||||
'id' => 2,
|
||||
'name' => 'news',
|
||||
'title' => 'News Community',
|
||||
'description' => 'Latest news',
|
||||
'nsfw' => false
|
||||
]
|
||||
]
|
||||
]
|
||||
], 200)
|
||||
]);
|
||||
|
||||
$service = new LemmyApiService('lemmy.world');
|
||||
$result = $service->listCommunities();
|
||||
|
||||
$this->assertArrayHasKey('communities', $result);
|
||||
$this->assertCount(2, $result['communities']);
|
||||
$this->assertEquals('technology', $result['communities'][0]['community']['name']);
|
||||
$this->assertEquals('news', $result['communities'][1]['community']['name']);
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
return str_contains($request->url(), '/api/v3/community/list')
|
||||
&& str_contains($request->url(), 'type_=Local')
|
||||
&& str_contains($request->url(), 'sort=Active')
|
||||
&& str_contains($request->url(), 'limit=50')
|
||||
&& str_contains($request->url(), 'page=1')
|
||||
&& str_contains($request->url(), 'show_nsfw=');
|
||||
});
|
||||
}
|
||||
|
||||
public function test_list_communities_success_with_custom_parameters(): void
|
||||
{
|
||||
Http::fake([
|
||||
'*' => Http::response([
|
||||
'communities' => [
|
||||
[
|
||||
'community' => [
|
||||
'id' => 3,
|
||||
'name' => 'gaming',
|
||||
'title' => 'Gaming Community',
|
||||
'description' => 'Gaming discussions',
|
||||
'nsfw' => false
|
||||
]
|
||||
]
|
||||
]
|
||||
], 200)
|
||||
]);
|
||||
|
||||
$service = new LemmyApiService('lemmy.world');
|
||||
$result = $service->listCommunities(
|
||||
'test-token',
|
||||
'All',
|
||||
'TopMonth',
|
||||
25,
|
||||
2,
|
||||
true
|
||||
);
|
||||
|
||||
$this->assertArrayHasKey('communities', $result);
|
||||
$this->assertCount(1, $result['communities']);
|
||||
$this->assertEquals('gaming', $result['communities'][0]['community']['name']);
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
return str_contains($request->url(), '/api/v3/community/list')
|
||||
&& str_contains($request->url(), 'type_=All')
|
||||
&& str_contains($request->url(), 'sort=TopMonth')
|
||||
&& str_contains($request->url(), 'limit=25')
|
||||
&& str_contains($request->url(), 'page=2')
|
||||
&& str_contains($request->url(), 'show_nsfw=1')
|
||||
&& $request->header('Authorization')[0] === 'Bearer test-token';
|
||||
});
|
||||
}
|
||||
|
||||
public function test_list_communities_success_without_token(): void
|
||||
{
|
||||
Http::fake([
|
||||
'*' => Http::response([
|
||||
'communities' => []
|
||||
], 200)
|
||||
]);
|
||||
|
||||
$service = new LemmyApiService('lemmy.world');
|
||||
$result = $service->listCommunities();
|
||||
|
||||
$this->assertArrayHasKey('communities', $result);
|
||||
$this->assertEmpty($result['communities']);
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
return !$request->hasHeader('Authorization');
|
||||
});
|
||||
}
|
||||
|
||||
public function test_list_communities_throws_on_unsuccessful_response(): void
|
||||
{
|
||||
Http::fake([
|
||||
'*' => Http::response('Server Error', 500)
|
||||
]);
|
||||
|
||||
Log::shouldReceive('warning')->once()->with('Failed to fetch communities list', Mockery::any());
|
||||
Log::shouldReceive('error')->once()->with('Exception while fetching communities list', Mockery::any());
|
||||
|
||||
$service = new LemmyApiService('lemmy.world');
|
||||
|
||||
$this->expectException(Exception::class);
|
||||
$this->expectExceptionMessage('Failed to fetch communities list: 500 - Server Error');
|
||||
|
||||
$service->listCommunities('token');
|
||||
}
|
||||
|
||||
public function test_list_communities_throws_on_404_response(): void
|
||||
{
|
||||
Http::fake([
|
||||
'*' => Http::response('Not Found', 404)
|
||||
]);
|
||||
|
||||
Log::shouldReceive('warning')->once();
|
||||
Log::shouldReceive('error')->once();
|
||||
|
||||
$service = new LemmyApiService('lemmy.world');
|
||||
|
||||
$this->expectException(Exception::class);
|
||||
$this->expectExceptionMessage('Failed to fetch communities list: 404');
|
||||
|
||||
$service->listCommunities('token');
|
||||
}
|
||||
|
||||
public function test_list_communities_returns_empty_communities_on_invalid_response_format(): void
|
||||
{
|
||||
Http::fake([
|
||||
'*' => Http::response([
|
||||
'invalid_format' => 'data'
|
||||
], 200)
|
||||
]);
|
||||
|
||||
Log::shouldReceive('warning')->once()->with('Invalid communities list response format', Mockery::any());
|
||||
|
||||
$service = new LemmyApiService('lemmy.world');
|
||||
$result = $service->listCommunities();
|
||||
|
||||
$this->assertArrayHasKey('communities', $result);
|
||||
$this->assertEmpty($result['communities']);
|
||||
}
|
||||
|
||||
public function test_list_communities_handles_exception(): void
|
||||
{
|
||||
Http::fake(function () {
|
||||
throw new Exception('Network error');
|
||||
});
|
||||
|
||||
Log::shouldReceive('error')->once()->with('Exception while fetching communities list', Mockery::any());
|
||||
|
||||
$service = new LemmyApiService('lemmy.world');
|
||||
|
||||
$this->expectException(Exception::class);
|
||||
$this->expectExceptionMessage('Network error');
|
||||
|
||||
$service->listCommunities('token');
|
||||
}
|
||||
|
||||
public function test_list_communities_with_all_sort_options(): void
|
||||
{
|
||||
$sortOptions = ['Hot', 'Active', 'New', 'TopDay', 'TopWeek', 'TopMonth', 'TopYear', 'TopAll'];
|
||||
|
||||
foreach ($sortOptions as $sort) {
|
||||
Http::fake([
|
||||
'*' => Http::response(['communities' => []], 200)
|
||||
]);
|
||||
|
||||
$service = new LemmyApiService('lemmy.world');
|
||||
$result = $service->listCommunities(null, 'Local', $sort);
|
||||
|
||||
$this->assertArrayHasKey('communities', $result);
|
||||
|
||||
Http::assertSent(function ($request) use ($sort) {
|
||||
return str_contains($request->url(), "sort={$sort}");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function test_list_communities_with_all_type_options(): void
|
||||
{
|
||||
$typeOptions = ['Local', 'All', 'Subscribed'];
|
||||
|
||||
foreach ($typeOptions as $type) {
|
||||
Http::fake([
|
||||
'*' => Http::response(['communities' => []], 200)
|
||||
]);
|
||||
|
||||
$service = new LemmyApiService('lemmy.world');
|
||||
$result = $service->listCommunities(null, $type);
|
||||
|
||||
$this->assertArrayHasKey('communities', $result);
|
||||
|
||||
Http::assertSent(function ($request) use ($type) {
|
||||
return str_contains($request->url(), "type_={$type}");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function test_list_communities_pagination_parameters(): void
|
||||
{
|
||||
Http::fake([
|
||||
'*' => Http::response(['communities' => []], 200)
|
||||
]);
|
||||
|
||||
$service = new LemmyApiService('lemmy.world');
|
||||
$result = $service->listCommunities(null, 'Local', 'Active', 100, 3);
|
||||
|
||||
$this->assertArrayHasKey('communities', $result);
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
return str_contains($request->url(), 'limit=100')
|
||||
&& str_contains($request->url(), 'page=3');
|
||||
});
|
||||
}
|
||||
|
||||
public function test_list_communities_nsfw_parameter(): void
|
||||
{
|
||||
Http::fake([
|
||||
'*' => Http::response(['communities' => []], 200)
|
||||
]);
|
||||
|
||||
$service = new LemmyApiService('lemmy.world');
|
||||
|
||||
// Test with NSFW enabled
|
||||
$result = $service->listCommunities(null, 'Local', 'Active', 50, 1, true);
|
||||
$this->assertArrayHasKey('communities', $result);
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
return str_contains($request->url(), 'show_nsfw=1');
|
||||
});
|
||||
|
||||
// Test with NSFW disabled
|
||||
$result = $service->listCommunities(null, 'Local', 'Active', 50, 1, false);
|
||||
$this->assertArrayHasKey('communities', $result);
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
return str_contains($request->url(), 'show_nsfw=');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -248,6 +248,44 @@ export interface KeywordRequest {
|
|||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface Community {
|
||||
id: number;
|
||||
name: string;
|
||||
title: string | null;
|
||||
description: string | null;
|
||||
nsfw: boolean;
|
||||
local: boolean;
|
||||
subscribers: number;
|
||||
posts: number;
|
||||
display_text: string;
|
||||
}
|
||||
|
||||
export interface CommunitiesResponse {
|
||||
communities: Community[];
|
||||
total: number;
|
||||
platform_instance: {
|
||||
id: number;
|
||||
name: string;
|
||||
url: string;
|
||||
};
|
||||
parameters: {
|
||||
type: string;
|
||||
sort: string;
|
||||
limit: number;
|
||||
page: number;
|
||||
show_nsfw: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CommunitiesRequest {
|
||||
platform_instance_id: number;
|
||||
type?: 'Local' | 'All' | 'Subscribed';
|
||||
sort?: 'Hot' | 'Active' | 'New' | 'TopDay' | 'TopWeek' | 'TopMonth' | 'TopYear' | 'TopAll';
|
||||
limit?: number;
|
||||
page?: number;
|
||||
show_nsfw?: boolean;
|
||||
}
|
||||
|
||||
// API Client class
|
||||
class ApiClient {
|
||||
constructor() {
|
||||
|
|
@ -475,6 +513,14 @@ class ApiClient {
|
|||
async deletePlatformAccount(id: number): Promise<void> {
|
||||
await axios.delete(`/platform-accounts/${id}`);
|
||||
}
|
||||
|
||||
// Platform Communities endpoints
|
||||
async getPlatformCommunities(params: CommunitiesRequest): Promise<CommunitiesResponse> {
|
||||
const response = await axios.get<ApiResponse<CommunitiesResponse>>('/platform-channels/communities', {
|
||||
params
|
||||
});
|
||||
return response.data.data;
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient();
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient, type ChannelRequest, type Language, type PlatformInstance } from '../../../lib/api';
|
||||
import { apiClient, type ChannelRequest, type Language, type PlatformInstance, type Community } from '../../../lib/api';
|
||||
|
||||
const ChannelStep: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -13,6 +13,10 @@ const ChannelStep: React.FC = () => {
|
|||
description: ''
|
||||
});
|
||||
const [errors, setErrors] = useState<Record<string, string[]>>({});
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const [, setSelectedCommunity] = useState<Community | null>(null);
|
||||
const [manualInput, setManualInput] = useState(false);
|
||||
|
||||
// Get onboarding options (languages, platform instances)
|
||||
const { data: options, isLoading: optionsLoading } = useQuery({
|
||||
|
|
@ -27,6 +31,31 @@ const ChannelStep: React.FC = () => {
|
|||
retry: false,
|
||||
});
|
||||
|
||||
// Fetch communities when platform instance is selected
|
||||
const { data: communitiesData, isLoading: communitiesLoading } = useQuery({
|
||||
queryKey: ['communities', formData.platform_instance_id],
|
||||
queryFn: () => apiClient.getPlatformCommunities({
|
||||
platform_instance_id: formData.platform_instance_id,
|
||||
type: 'Local',
|
||||
sort: 'Active',
|
||||
limit: 100
|
||||
}),
|
||||
enabled: formData.platform_instance_id > 0 && !manualInput,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// Filter communities based on search term
|
||||
const filteredCommunities = useMemo(() => {
|
||||
if (!communitiesData?.communities) return [];
|
||||
if (!searchTerm) return communitiesData.communities;
|
||||
|
||||
return communitiesData.communities.filter(community =>
|
||||
community.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
community.title?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
community.description?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
}, [communitiesData?.communities, searchTerm]);
|
||||
|
||||
// Pre-fill form with existing data
|
||||
useEffect(() => {
|
||||
if (channels && channels.length > 0) {
|
||||
|
|
@ -37,9 +66,34 @@ const ChannelStep: React.FC = () => {
|
|||
language_id: firstChannel.language_id || 0,
|
||||
description: firstChannel.description || ''
|
||||
});
|
||||
setSearchTerm(firstChannel.name || '');
|
||||
}
|
||||
}, [channels]);
|
||||
|
||||
// Reset community selection when platform instance changes
|
||||
useEffect(() => {
|
||||
setSelectedCommunity(null);
|
||||
setSearchTerm('');
|
||||
if (formData.platform_instance_id === 0) {
|
||||
setManualInput(false);
|
||||
}
|
||||
}, [formData.platform_instance_id]);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (!target.closest('[data-dropdown]')) {
|
||||
setShowDropdown(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (showDropdown) {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
return () => document.removeEventListener('click', handleClickOutside);
|
||||
}
|
||||
}, [showDropdown]);
|
||||
|
||||
const createChannelMutation = useMutation({
|
||||
mutationFn: (data: ChannelRequest) => apiClient.createChannelForOnboarding(data),
|
||||
onSuccess: () => {
|
||||
|
|
@ -47,11 +101,12 @@ const ChannelStep: React.FC = () => {
|
|||
queryClient.invalidateQueries({ queryKey: ['onboarding-status'] });
|
||||
navigate('/onboarding/route');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
if (error.response?.data?.errors) {
|
||||
setErrors(error.response.data.errors);
|
||||
onError: (error: unknown) => {
|
||||
const errorData = error as { response?: { data?: { errors?: Record<string, string[]>; message?: string } } };
|
||||
if (errorData.response?.data?.errors) {
|
||||
setErrors(errorData.response.data.errors);
|
||||
} else {
|
||||
setErrors({ general: [error.response?.data?.message || 'An error occurred'] });
|
||||
setErrors({ general: [errorData.response?.data?.message || 'An error occurred'] });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -70,6 +125,34 @@ const ChannelStep: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleCommunitySelect = (community: Community) => {
|
||||
setSelectedCommunity(community);
|
||||
setFormData(prev => ({ ...prev, name: community.name }));
|
||||
setSearchTerm(community.name);
|
||||
setShowDropdown(false);
|
||||
if (errors.name) {
|
||||
setErrors(prev => ({ ...prev, name: [] }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearchTerm(value);
|
||||
setFormData(prev => ({ ...prev, name: value }));
|
||||
setShowDropdown(true);
|
||||
setSelectedCommunity(null);
|
||||
if (errors.name) {
|
||||
setErrors(prev => ({ ...prev, name: [] }));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleManualInput = () => {
|
||||
setManualInput(prev => !prev);
|
||||
setSelectedCommunity(null);
|
||||
setSearchTerm('');
|
||||
setFormData(prev => ({ ...prev, name: '' }));
|
||||
setShowDropdown(false);
|
||||
};
|
||||
|
||||
if (optionsLoading) {
|
||||
return <div className="text-center">Loading...</div>;
|
||||
}
|
||||
|
|
@ -97,19 +180,97 @@ const ChannelStep: React.FC = () => {
|
|||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Community Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleChange('name', e.target.value)}
|
||||
placeholder="technology"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
<p className="text-sm text-gray-500 mt-1">Enter the community name (without the @ or instance)</p>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
||||
Community Name
|
||||
</label>
|
||||
{formData.platform_instance_id > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleManualInput}
|
||||
className="text-sm text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
{manualInput ? 'Use dropdown' : 'Enter manually'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{manualInput || formData.platform_instance_id === 0 ? (
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleChange('name', e.target.value)}
|
||||
placeholder="technology"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
) : (
|
||||
<div className="relative" data-dropdown>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
value={searchTerm}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
onFocus={() => setShowDropdown(true)}
|
||||
placeholder="Search communities..."
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
|
||||
{communitiesLoading && (
|
||||
<div className="absolute right-3 top-3">
|
||||
<div className="animate-spin h-4 w-4 border-2 border-blue-500 border-t-transparent rounded-full"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showDropdown && !communitiesLoading && filteredCommunities.length > 0 && (
|
||||
<div className="absolute z-10 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-y-auto">
|
||||
{filteredCommunities.slice(0, 10).map((community) => (
|
||||
<button
|
||||
key={community.id}
|
||||
type="button"
|
||||
onClick={() => handleCommunitySelect(community)}
|
||||
className="w-full text-left px-3 py-2 hover:bg-gray-50 focus:bg-gray-50 focus:outline-none border-b border-gray-100 last:border-b-0"
|
||||
>
|
||||
<div className="font-medium text-gray-900">{community.title || community.name}</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
!{community.name} • {community.subscribers.toLocaleString()} subscribers
|
||||
</div>
|
||||
{community.description && (
|
||||
<div className="text-xs text-gray-400 mt-1 truncate">{community.description}</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{filteredCommunities.length > 10 && (
|
||||
<div className="px-3 py-2 text-sm text-gray-500 bg-gray-50">
|
||||
And {filteredCommunities.length - 10} more communities...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showDropdown && !communitiesLoading && searchTerm && filteredCommunities.length === 0 && (
|
||||
<div className="absolute z-10 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg p-3">
|
||||
<div className="text-sm text-gray-500">No communities found matching "{searchTerm}"</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleManualInput}
|
||||
className="text-sm text-blue-600 hover:text-blue-800 mt-1"
|
||||
>
|
||||
Enter community name manually
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{manualInput || formData.platform_instance_id === 0
|
||||
? 'Enter the community name (without the @ or instance)'
|
||||
: 'Search and select from available communities, or enter manually'
|
||||
}
|
||||
</p>
|
||||
{errors.name && (
|
||||
<p className="text-red-600 text-sm mt-1">{errors.name[0]}</p>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Reference in a new issue