mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
O fail-closed silencioso da S3 deixava a assessoria cega: token errado não deixava rastro além do picker vazio. Fim da caixa-preta: - `instagramSyncStatus` (jsonb hidden) na global `social-feed-settings`, escrito pelo render, pelo afterChange (sync awaited quando a credencial IG muda) e pela rota `POST /api/social-feed/sync` (botão, auth admin + same-origin). - `InstagramApiError` + `describeInstagramError`: o fetch parseia o body de erro da Graph API e o mapeia para copy pt-BR com a correção (OAuthException/expired → "token inválido/expirado ou emitido via Facebook Login — gere pelo Instagram Login"; "Invalid user id" → confira o ID; 5xx → indisponível; TypeError → rede; AbortError → demorou demais). O ID só casa com "user" E "id" juntos (token rejections real falam "the user must be logged in" — não podem virar a branch de ID). - `InstagramSyncStatusPanel` (campo ui irmão do picker): 5 estados fiéis ao rascunho; gate respeita o master `enabled`; `outcome` transiente limpo ao editar credencial; aria-live nos boxes interativos. Home inalterada (fail-closed intacto). - Bug real: `afterChange` roda dentro da transação do save (row lock até os hooks retornarem); persistir o status via pool numa 2ª conexão deadlockava no lock da própria linha (~80-230s/save). Fix: persists do sync-hook na MESMA transação via `getPostgresTransactionDatabase` (C6); render/rota sem transação seguem no pool. Diagrama: UPDATE transactionid Lock vs idle in transaction; fix verificado 45ms vs 232s. - Stub IG ganha estado `invalid-token`; runbook docs/ops/instagram-feed-token-runbook.md; migration add_instagram_sync_status (jsonb nullable). - Débitos registrados: #124 (janela de lock) e #125 (polish P3). - Gates: unit 2463 + int 791; lint/format/tsc/cycles; build; e2e admin 5/5 + frontend serial 19/19.
270 lines
9.8 KiB
TypeScript
270 lines
9.8 KiB
TypeScript
import { login } from '../helpers/login'
|
|
import { cleanupTestUser, seedTestUser, testUser } from '../helpers/seedUser'
|
|
import { instagramStubUrlFor } from '../helpers/socialStub'
|
|
import { expect, test } from './fixtures/e2eTest'
|
|
|
|
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:3000'
|
|
|
|
test.describe('Admin Panel', () => {
|
|
test.beforeAll(async ({}, testInfo) => {
|
|
testInfo.setTimeout(90_000)
|
|
await seedTestUser()
|
|
})
|
|
|
|
test.beforeEach(async ({ page }) => {
|
|
await login({ page, serverURL: baseURL, user: testUser })
|
|
})
|
|
|
|
test.afterAll(async () => {
|
|
await cleanupTestUser()
|
|
})
|
|
|
|
test('can navigate to dashboard', async ({ page }) => {
|
|
await page.goto(`${baseURL}/admin`)
|
|
await expect(page).toHaveURL(`${baseURL}/admin`)
|
|
const dashboardArtifact = page.getByRole('heading', { name: 'Coleções', exact: true })
|
|
await expect(dashboardArtifact).toBeVisible()
|
|
})
|
|
|
|
test('can navigate to list view', async ({ page }) => {
|
|
await page.goto(`${baseURL}/admin/collections/users`)
|
|
await expect(page).toHaveURL(new RegExp(`^${baseURL}/admin/collections/users(?:\\?|$)`))
|
|
const listViewArtifact = page.getByRole('heading', { name: 'Usuários', exact: true })
|
|
await expect(listViewArtifact).toBeVisible()
|
|
})
|
|
|
|
test('can navigate to edit view', async ({ page }) => {
|
|
await page.goto(`${baseURL}/admin/collections/users/create`)
|
|
await expect(page).toHaveURL(/\/admin\/collections\/users\/[a-zA-Z0-9-_]+/)
|
|
const editViewArtifact = page.locator('input[name="email"]')
|
|
await expect(editViewArtifact).toBeVisible()
|
|
})
|
|
|
|
test('can exclude an Instagram post through the content board picker', async ({
|
|
page,
|
|
request,
|
|
}) => {
|
|
// Seed the social-feed global with an Instagram snapshot so the exclusion
|
|
// picker (S3) has posts to show. The snapshot is plain data — the picker
|
|
// lists it and writes `excludedItems` (the same mechanism YouTube uses).
|
|
const login = await request.post(`${baseURL}/api/users/login`, {
|
|
data: { email: testUser.email, password: testUser.password },
|
|
})
|
|
expect(login.ok()).toBeTruthy()
|
|
const { token } = await login.json()
|
|
const headers = { cookie: `payload-token=${token}` }
|
|
|
|
const snapshot = {
|
|
username: 'depjorgesolla',
|
|
posts: [
|
|
{
|
|
id: 'e2e-ig-muro-1',
|
|
caption: 'E2e Post do muro',
|
|
mediaType: 'IMAGE',
|
|
permalink: 'https://www.instagram.com/p/e2e-ig-muro-1/',
|
|
thumbnailUrl: null,
|
|
timestamp: '2026-08-18T10:00:00+00:00',
|
|
},
|
|
{
|
|
id: 'e2e-ig-reel-2',
|
|
caption: 'E2e Reel da caravana',
|
|
mediaType: 'REEL',
|
|
permalink: 'https://www.instagram.com/p/e2e-ig-reel-2/',
|
|
thumbnailUrl: null,
|
|
timestamp: '2026-08-18T09:00:00+00:00',
|
|
},
|
|
],
|
|
}
|
|
const seed = await request.post(`${baseURL}/api/globals/social-feed-settings`, {
|
|
headers,
|
|
data: {
|
|
instagramEnabled: true,
|
|
instagramAccessToken: 'e2e-ig-token',
|
|
instagramUserId: '17841400000000000',
|
|
instagramMaxItems: 3,
|
|
excludedItems: [],
|
|
instagramFeedSnapshot: snapshot,
|
|
},
|
|
})
|
|
expect(seed.ok()).toBeTruthy()
|
|
|
|
try {
|
|
await page.goto(`${baseURL}/admin/globals/social-feed-settings`)
|
|
await expect(page.getByText('E2e Post do muro')).toBeVisible()
|
|
|
|
const hideButton = page.getByRole('button', { name: 'Não exibir' }).first()
|
|
await expect(hideButton).toBeVisible()
|
|
await hideButton.click()
|
|
await expect(page.getByRole('button', { name: 'Reexibir' })).toBeVisible()
|
|
|
|
const saveResponse = page.waitForResponse(
|
|
(response) =>
|
|
response.url().includes('/api/globals/social-feed-settings') &&
|
|
response.request().method() === 'POST',
|
|
)
|
|
await page.getByRole('button', { name: 'Salvar' }).click()
|
|
await saveResponse
|
|
|
|
const read = await request.get(`${baseURL}/api/globals/social-feed-settings`, { headers })
|
|
expect(read.ok()).toBeTruthy()
|
|
const { excludedItems } = await read.json()
|
|
expect(excludedItems).toEqual([
|
|
expect.objectContaining({ platform: 'instagram', itemId: 'e2e-ig-muro-1' }),
|
|
])
|
|
} finally {
|
|
await request
|
|
.post(`${baseURL}/api/globals/social-feed-settings`, {
|
|
headers,
|
|
data: {
|
|
enabled: true,
|
|
youtubeEnabled: true,
|
|
youtubeApiKey: '',
|
|
youtubeChannelId: '',
|
|
youtubeMaxItems: 3,
|
|
instagramEnabled: true,
|
|
instagramAccessToken: '',
|
|
instagramUserId: '',
|
|
instagramMaxItems: 3,
|
|
excludedItems: [],
|
|
youtubeFeedSnapshot: null,
|
|
instagramFeedSnapshot: null,
|
|
instagramSyncStatus: null,
|
|
},
|
|
})
|
|
.catch(() => undefined)
|
|
}
|
|
})
|
|
|
|
test('shows the Instagram sync status and retries the sync from the panel', async ({
|
|
page,
|
|
request,
|
|
}) => {
|
|
// The seed POST fires the global's afterChange hook, whose sync hits the
|
|
// stub — put it in a known state first and let the hook persist the
|
|
// status so the panel has a real one to render.
|
|
const stubUrl = instagramStubUrlFor(baseURL)
|
|
const setStub = async (state: 'ok' | 'fail' | 'invalid-token') => {
|
|
const response = await request.post(`${stubUrl}/__stub/state`, { data: { state } })
|
|
expect(response.ok()).toBeTruthy()
|
|
}
|
|
await setStub('ok')
|
|
|
|
const login = await request.post(`${baseURL}/api/users/login`, {
|
|
data: { email: testUser.email, password: testUser.password },
|
|
})
|
|
expect(login.ok()).toBeTruthy()
|
|
const { token } = await login.json()
|
|
const headers = { cookie: `payload-token=${token}` }
|
|
|
|
const seed = await request.post(`${baseURL}/api/globals/social-feed-settings`, {
|
|
headers,
|
|
data: {
|
|
enabled: true,
|
|
instagramEnabled: true,
|
|
instagramAccessToken: 'e2e-ig-token',
|
|
instagramUserId: '17841400000000000',
|
|
instagramMaxItems: 3,
|
|
excludedItems: [],
|
|
instagramFeedSnapshot: null,
|
|
},
|
|
})
|
|
expect(seed.ok()).toBeTruthy()
|
|
|
|
const seedToken = async (accessToken: string) => {
|
|
const response = await request.post(`${baseURL}/api/globals/social-feed-settings`, {
|
|
headers,
|
|
data: {
|
|
enabled: true,
|
|
instagramEnabled: true,
|
|
instagramAccessToken: accessToken,
|
|
instagramUserId: '17841400000000000',
|
|
instagramMaxItems: 3,
|
|
excludedItems: [],
|
|
instagramFeedSnapshot: null,
|
|
},
|
|
})
|
|
expect(response.ok()).toBeTruthy()
|
|
}
|
|
|
|
try {
|
|
await page.goto(`${baseURL}/admin/globals/social-feed-settings`)
|
|
await expect(page.getByText(/Sincronizado · .* · 5 posts/)).toBeVisible()
|
|
|
|
// Credential change + save triggers the hook sync — with the API now
|
|
// rejecting the token (OAuthException), the save persists a failure
|
|
// status and the reload shows it with the retry button.
|
|
await setStub('invalid-token')
|
|
await seedToken('e2e-ig-token-2')
|
|
await page.goto(`${baseURL}/admin/globals/social-feed-settings`)
|
|
await expect(page.getByText('Falha na última sincronização')).toBeVisible()
|
|
await expect(page.getByText(/token do Instagram foi recusado/)).toBeVisible()
|
|
|
|
// Retry after fixing: the button runs the sync route → synchronized.
|
|
await setStub('ok')
|
|
await page.getByRole('button', { name: 'Tentar sincronizar de novo' }).click()
|
|
await expect(page.getByText(/Sincronizado · .* · 5 posts/)).toBeVisible()
|
|
|
|
const read = await request.get(`${baseURL}/api/globals/social-feed-settings`, { headers })
|
|
expect(read.ok()).toBeTruthy()
|
|
const { instagramSyncStatus } = await read.json()
|
|
expect(instagramSyncStatus).toEqual(
|
|
expect.objectContaining({
|
|
lastSyncAt: expect.any(String),
|
|
postCount: 5,
|
|
}),
|
|
)
|
|
expect(instagramSyncStatus.error).toBeUndefined()
|
|
|
|
// Fail-500 scene: a server-side API failure maps to the generic notice.
|
|
await setStub('fail')
|
|
await seedToken('e2e-ig-token-3')
|
|
await page.goto(`${baseURL}/admin/globals/social-feed-settings`)
|
|
await expect(page.getByText('Falha na última sincronização')).toBeVisible()
|
|
await expect(page.getByText(/indisponível/)).toBeVisible()
|
|
|
|
// Disabled scene: the master kill switch off renders the disabled box.
|
|
await setStub('ok')
|
|
await request.post(`${baseURL}/api/globals/social-feed-settings`, {
|
|
headers,
|
|
data: {
|
|
enabled: false,
|
|
instagramEnabled: true,
|
|
instagramAccessToken: 'e2e-ig-token',
|
|
instagramUserId: '17841400000000000',
|
|
instagramMaxItems: 3,
|
|
excludedItems: [],
|
|
instagramFeedSnapshot: null,
|
|
},
|
|
})
|
|
await page.goto(`${baseURL}/admin/globals/social-feed-settings`)
|
|
await expect(page.getByText('Instagram desativado')).toBeVisible()
|
|
|
|
// Unconfigured state: no credentials (feed enabled) → the panel says so.
|
|
await seedToken('')
|
|
await page.goto(`${baseURL}/admin/globals/social-feed-settings`)
|
|
await expect(page.getByText('Instagram ainda não configurado')).toBeVisible()
|
|
} finally {
|
|
await setStub('ok').catch(() => undefined)
|
|
await request
|
|
.post(`${baseURL}/api/globals/social-feed-settings`, {
|
|
headers,
|
|
data: {
|
|
enabled: true,
|
|
youtubeEnabled: true,
|
|
youtubeApiKey: '',
|
|
youtubeChannelId: '',
|
|
youtubeMaxItems: 3,
|
|
instagramEnabled: true,
|
|
instagramAccessToken: '',
|
|
instagramUserId: '',
|
|
instagramMaxItems: 3,
|
|
excludedItems: [],
|
|
youtubeFeedSnapshot: null,
|
|
instagramFeedSnapshot: null,
|
|
instagramSyncStatus: null,
|
|
},
|
|
})
|
|
.catch(() => undefined)
|
|
}
|
|
})
|
|
})
|