Commit graph

16 commits

Author SHA1 Message Date
Paulo Castellano
04975020e4 test(social): tests pull OAuth URLs from config
Same rule applied to production code in earlier commits now applies to
tests: Http::fake patterns and assertions read from
config('trypost.platforms.*.oauth_api' / '.api' / '.default_service')
instead of hardcoded strings. Hardcoded URLs in tests drift silently
when the config changes.

Also documents the rule in CLAUDE.md under "External Service URLs" so
new code (and tests) start in the right place — only the host comes
from config, path/RPC segments stay inline next to the call.
2026-05-19 09:37:16 -03:00
Paulo Castellano
f975171a9a test(social): close coverage gaps for TokenRedactor and 429 handling
- TokenRedactorTest (7 unit tests): all four regex patterns, multiple
  secrets in one body, null input, and the no-op pass-through case.
  Guards against silent regression of the redaction regexes (which
  previously drifted across three duplicated copies).
- ConnectionVerifierTest: HTTP 429 during refresh raises
  PlatformUnavailableException, not TokenExpiredException. Locks in
  the rate-limit-as-transient behavior added when consolidating the
  refresh logic.
2026-05-19 09:34:04 -03:00
Paulo Castellano
090cc761dd test(social): smoke tests for analytics refactored in this PR
LinkedInPageAnalytics and MastodonAnalytics were the only two of the 11
files refactored to read OAuth host / default instance from config that
had zero test coverage. Adds smoke tests that assert the HTTP request
hits the configured URL, so a typo in the config key (e.g. linkedin.api
vs linkedin.oauth_api) would now fail loudly.
2026-05-19 08:42:02 -03:00
Paulo Castellano
d5e28e3d02 fix(social): move Mastodon default instance to config + cleanup
Review follow-ups:

- verifyMastodon was the last hardcoded host left after the PR moved
  LinkedIn/YouTube/Bluesky to config. Adds trypost.platforms.mastodon
  .default_instance (env MASTODON_DEFAULT_INSTANCE) and reads from it.
- refreshToken() docblock now declares @throws PlatformUnavailableException
  (the whole point of the PR was missing from its contract).
- Strip the new explanatory comments inside catch blocks and tests —
  rationale lives in the commit / PR, not inline. The two comments
  inside empty `catch (TokenExpiredException) {}` blocks stay because
  there the comment is the only thing telling the reader why the
  exception is swallowed.
2026-05-19 08:27:46 -03:00
Paulo Castellano
6f96d67dbc fix(social): distinguish platform-down from token-expired
When a provider's API was down (5xx, timeout, DNS), the hourly
RefreshSocialToken job and daily VerifyWorkspaceConnections job were
treating it as "token revoked" and emailing the user to reconnect.
Bluesky going offline triggered false-positive disconnect notifications
because Bluesky access tokens are short-lived (2h) so every hourly
refresh failed during the outage.

- New PlatformUnavailableException: API unreachable / 5xx, transient.
  TokenExpiredException stays for 4xx (token is provably bad).
- New TokenRefreshClient: normalizes failure semantics for OAuth
  refresh HTTP calls across all providers. Takes a Platform enum so
  typos fail at compile time and the user-facing label comes from
  one source.
- ConnectionVerifier: all 8 refresh*Token methods route through the
  new client. Hardcoded OAuth URLs (LinkedIn, YouTube) and Bluesky's
  default PDS host moved into config/trypost.php alongside the
  existing per-platform entries.
- RefreshSocialToken job: PlatformUnavailableException → log warning
  and stop. Do NOT markAsTokenExpired, do NOT notify the user. Next
  scheduled tick retries.
- VerifyWorkspaceConnections job: PlatformUnavailableException from
  the inner refresh propagates and is treated as a transient skip.
2026-05-19 08:16:43 -03:00
Paulo Castellano
44d891ef08 fix(pinterest): restore board picker + require board_id in validation
The post editor lost the Pinterest board picker during a UI rewrite,
causing scheduled posts to fail in production with 'Pinterest board_id
is required'. This restores the picker and locks the contract with
validation + tests so the regression cannot happen silently again.

Backend:
- PostController: pinterestBoards is now Record<account_id, Board[]>
  (mirrors the TikTok creator-info pattern); supports multi-account.
- UpdatePostRequest: 'platforms.*.meta.board_id' rule + after-validator
  rejects Publishing/Scheduling Pinterest posts without board_id.

Frontend:
- PinterestSettings.vue: Combobox board picker with empty-state warning;
  emits update:meta with board_id.
- ScheduleTab / PostEditorSidebar / Edit pass pinterestBoards down by
  social_account_id.

Tests (6 new):
- UpdatePostRequestTest: rejects publishing/scheduling without board_id
  across pin/carousel/video pin; allows draft without board_id;
  pinterest error doesn't block sibling platforms in multi-platform.
- PinterestPublisherTest: publisher throws for carousel + video pin
  when no board_id (existing image-pin case kept).

1542 tests passing.
2026-05-15 11:51:20 -03:00
Paulo Castellano
62e5d6da57 test(linkedin-sync): cover same-workspace cross-admin isolation
Adds an explicit test asserting that when a workspace has multiple
LinkedIn users (e.g., the owner plus a client teammate), syncing
tokens from one user's accounts never touches accounts admin'd by a
different LinkedIn user — even though all live in the same workspace.

The behavior is already correct by virtue of the
`admin_user_id` / `platform_user_id` match in the synchronizer's
query, but the property is now part of the test contract instead of
emergent.
2026-05-14 11:04:04 -03:00
Paulo Castellano
ee18b489e6 refactor(facebook): cleanup publishReel after review
Five small fixes scoped to the publishReel method:

1. Replace generic \Exception on media download failure with a typed
   FacebookPublishException(ServerError). The generic exception was
   landing in the \Throwable catch in PublishToSocialPlatform with
   category 'unknown', defeating the whole point of the social
   exception hierarchy.

2. Replace handleApiError($startResponse) with a direct
   FacebookPublishException throw when video_id/upload_url are missing.
   The previous code passed a successful HTTP response into a method
   built for error responses — fromApiResponse would fall into the
   default arm and surface 'An unknown Facebook error occurred.'
   ironically reintroducing the same bad UX we just spent the day
   fixing.

3. Drop the four redundant Log::error calls before handleApiError.
   FacebookPublishException::fromApiResponse already pulls the FB
   error code/subcode/message into platformErrorCode + userMessage,
   and the downstream catch in PublishToSocialPlatform::handle logs
   the exception anyway (Nightwatch picks that up). Same pattern as
   the X cleanup in PR #29.

4. Stream the upload body via fopen() resource instead of
   file_get_contents(). Eliminates loading the whole video into memory
   for large reels.

5. Replace \@unlink with unlink + Log::warning. Surfaces temp-file
   cleanup failures instead of silently leaking files.

Tests:
- Strengthened the missing-upload_url test to assert the exception
  message and class.
- Added a typed-exception test for the media-download failure path
  (would have caught the regression where we used a generic
  \Exception).
- Full suite green: 1505 passed, 2 skipped, 0 failed.
2026-05-12 20:43:32 -03:00
Paulo Castellano
1d5d6ec063 fix(facebook): switch reel transfer to binary upload with Offset/file_size headers
Previous attempt at the hosted-file flow (`file_url` in JSON body) hit
the rupload.facebook.com validator with HTTP 400:

  'HeaderValuePredicate: Header Offset not convertable to unsigned long'

Facebook's rupload endpoint requires the `Offset` and `file_size` headers
regardless of whether the upload is local or hosted-file. The docs
describe the hosted-file path without them, but in practice rupload
rejects requests that lack them.

Switching to the well-documented local-file flow:
- Download the media to a temp file via Http sink (already a pattern
  used in XPublisher for media downloads).
- POST raw bytes to upload_url with three headers:
  - Authorization: OAuth {token}
  - Offset: 0
  - file_size: {actual bytes}
- Use mime_type from the media item as the Content-Type.
- Always cleanup the temp file in a finally block.

Tests updated to fake the media-download GET (returning bytes that the
publisher then re-uploads) and to assert on the Offset/file_size/
Authorization headers being present and correct. The existing
"cleans up temp files after reel upload" test now actually exercises
its assertion since this code path creates temp files again.

Full suite: 1504 passed, 2 skipped, 0 failed.
2026-05-12 20:35:54 -03:00
Paulo Castellano
c379c4c14f fix(facebook): use upload_url + file_url for reel transfer phase
Production was failing every Facebook reel publish with the cryptic
'Video Upload Is Missing' / error_subcode 1363130. Root cause: our
'transfer' phase was making up its own API contract that doesn't exist.

The Reels publishing API (per Meta docs) is a 3-step flow:

  1. start → POST /{page_id}/video_reels {upload_phase=start}
             returns {video_id, upload_url}
  2. transfer → POST {upload_url} on rupload.facebook.com
                Header: Authorization: OAuth {token}
                Body (JSON): {"file_url": "https://..."}
  3. finish → POST /{page_id}/video_reels {upload_phase=finish, ...}

Our code was doing:

  2 (broken) → POST /{video_id} on graph endpoint
               Body: {video_file_chunk: '<some URL>', access_token: ...}

`video_file_chunk` is not a real parameter; the graph endpoint accepted
the request (returning 200) but nothing was actually uploaded, so the
finish phase reported 'Video was not uploaded'.

Changes:
- Capture upload_url from the start response and use it for transfer.
- POST to that upload_url with file_url in JSON body and
  'Authorization: OAuth ...' header (the format docs.facebook.com
  documents for the hosted-file flow).
- Bail with handleApiError if the start response is missing video_id
  or upload_url so we don't silently no-op like before.

Tests:
- Existing 'can publish reel' test updated to include upload_url in the
  start mock and assert the transfer call actually POSTs to rupload
  with file_url + OAuth header (would have caught this bug before
  shipping).
- New 'fails reel publish when start does not return upload_url' test
  for the safety bail-out.
- Pre-existing 'cleans up temp files after reel upload' test updated
  to match the new mock pattern.

Full suite green: 1504 passed, 2 skipped, 0 failed.
2026-05-12 20:32:20 -03:00
Paulo Castellano
d349e499b5 test: move publisher tests from Unit to Feature
These tests use Http::fake, model factories, and DB — by Laravel/Pest
convention that's a feature test, not a unit test. Moving them to the
Feature suite to match the convention.

No code changes. Tests still pass: 1493 passed, 2 skipped, 0 failed.
2026-05-12 19:02:51 -03:00
Paulo Castellano
595510812c refactor: implement lazy token refreshing and persist Mastodon scopes 2026-05-03 21:58:25 -03:00
Paulo Castellano
dafdd5da43 feat: media gallery picker, custom emoji picker, preview tabs, real platform logos
- gallery: extract /assets tabs (uploads, Unsplash, Giphy) into shared
  GalleryBrowser used by both /assets and a new MediaPickerDialog inside the
  post editor; add JSON search endpoint for workspace assets with tests
- emoji: replace broken emoji-picker-element web component with a custom
  EmojiPicker (full Unicode set, search, categories, recently-used,
  light/dark, i18n)
- preview tab: platform selector pills, variant tabs (data-driven from
  content_types map) so the user can switch Feed/Reel/Story etc. and have
  it autosave through the same handler ScheduleTab uses
- platform logos: shared usePlatformLogo composable (logo + label + content
  types); replaces inline maps across 5 components, fixes
  instagram-facebook falling back to default.png
- tooltips: hover details (display_name · @username + platform label) on
  platform avatars across editor, posts list and calendar
- settings cards: show ` · @username` in the title bar so multiple accounts
  on the same network are distinguishable
- routes: drop the throttle:6,1 group middleware on social connect routes
  (was 429ing legitimate OAuth retries) and rely on the default limiter
2026-05-01 14:53:49 -03:00
Paulo Castellano
9f3b8e547a fix: overhaul social publishing — validation, uploads, token refresh
- Fix UpdatePostRequest missing content_type, synced, meta fields
  (content_type was silently dropped, causing Instagram Reels to post as Feed)
- Create API FormRequests (StorePostRequest, UpdatePostRequest) replacing inline validation
- Fix syntax errors in all publishers ($media->isVideo() missing variable)
- Fix Instagram Feed with single video calling publishSingleImage instead of publishReel
- Fix TikTok hardcoded SELF_ONLY privacy — now queries creator_info API
- Refactor YouTubePublisher to use google/apiclient SDK with chunked resumable upload
- Fix all publishers using file_get_contents for large videos (memory overflow)
  — X, LinkedIn, LinkedInPage, Pinterest, Bluesky, Mastodon now use temp file + stream
- Fix Media::isVideo/isImage to use mime_type instead of extension
- Fix Threads not saving refresh_token (was null, now saves access_token)
- Add Instagram token refresh to publisher and ConnectionVerifier
- Fix PublishToSocialPlatform job: tries 3→1 (prevents duplicate uploads),
  timeout 60→600s, added failed() method for cleanup
- Increase Horizon worker timeout 60→630s, Redis retry_after 90→660s
- Increase upload limit 500MB→1GB
- Add mastodon to getDefaultContentType in Edit.vue
2026-03-31 19:25:19 -03:00
Paulo Castellano
56b8c92e72 refactor: settings redesign, Spanish translations, language system, strict_types
Settings pages:
- Redesign layout to match Sendkit (max-w-4xl, space-y-12, Separator sections)
- Merge Members page into Workspace settings with Table, invite Dialog, ConfirmDeleteModal
- Add workspace logo upload/delete routes and controller methods
- Translate all hardcoded strings in Workspace.vue modals

Language system:
- Drop languages table, replace language_id FK with locale string column on users
- Create config/languages.php for available languages and default locale
- Add Spanish (es) translations (13 files)
- Simplify HandleInertiaRequests, ProfileController, RegisteredUserController

Code quality:
- Add declare(strict_types=1) to all PHP files
- Fix MastodonPublisher using wrong attribute (filename -> original_filename)
- Fix HasMediaTest for new has_photo/photo_url accessors
- Fix PublishToSocialPlatformTest type error revealed by strict_types
- Remove orphaned Language model from AppServiceProvider morph map
- Update User TypeScript interface (has_photo, photo_url, locale)
- Eager load media relation on workspaces to prevent N+1
- Add 8 new tests for workspace logo upload/delete
- Update workspace settings test to assert members/invitations props

All 710 tests passing.
2026-03-30 00:20:43 -03:00
Paulo Castellano
a976e11263 fix: refresh tokens before verifying social connections and fix dark mode
- ConnectionVerifier now refreshes expired/expiring tokens before verification
- Prevents false disconnection emails when tokens are expired but refreshable
- Added refresh methods for LinkedIn, X, Bluesky, YouTube, TikTok, Pinterest, Threads
- Fixed dark mode for calendar post items (status colors)
- Fixed dark mode for platform tabs in post editor
- Added tests for token refresh behavior
2026-01-26 10:06:54 -03:00