Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion layers/identity/app/composables/useLikes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,18 @@ export function useLikes() {
return !wasLiked
}

/** Account removal must also update hearts already loaded in this session. */
async function remove(ref: SkillLikeRef): Promise<LikeMutationResponse> {
// An older list response must settle before it can overwrite the removal.
await ensureLoaded()
const key = likeKey(ref)
const path = `/api/me/likes/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.repo)}/${encodeURIComponent(ref.name)}`
const result = await $fetch<LikeMutationResponse>(path, { method: 'DELETE' })
liked.value = applyLiked(liked.value, key, false)
likeCounts.value = { ...likeCounts.value, [key]: result.likeCount }
return result
}

/** Idempotent unlike `toggle`: automatic flows can only move toward liked. */
async function ensureLiked(ref: SkillLikeRef): Promise<boolean> {
if (!isAuthenticated.value)
Expand Down Expand Up @@ -184,7 +196,7 @@ export function useLikes() {
return request
}

return { liked, isLiked, isPending, likeCount, observeLikeCount, ensureLoaded, ensureLiked, toggle }
return { liked, isLiked, isPending, likeCount, observeLikeCount, ensureLoaded, ensureLiked, toggle, remove }
}

function applyLiked(map: Record<string, true>, key: string, next: boolean): Record<string, true> {
Expand Down
13 changes: 11 additions & 2 deletions layers/identity/app/pages/login.vue
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,17 @@ const actionMessage = computed(() => {
})

watchEffect(async () => {
if (loggedIn.value)
await navigateTo(returnTo.value || '/me', { replace: true })
if (!loggedIn.value || error.value)
return

// Public pages can still offer anonymous Like or Watch links after another
// tab signs in. Keep the intent on the sealed OAuth replay path instead of
// returning to the Skill without applying it. Failed replays stay retryable.
if (returnTo.value && ['like-skill', 'watch-skill', 'watch-collection'].includes(action.value)) {
await navigateTo(href.value, { external: true, replace: true })
return
}
await navigateTo(returnTo.value || '/me', { replace: true })
})

useSeoMeta({
Expand Down
4 changes: 2 additions & 2 deletions layers/identity/app/pages/me/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ const {
const actionFailed = useActionFailure()
const rpc = useNuxtRpc()
const { fetchSession } = useAuth()
const { remove: removeLike } = useLikes()

async function refreshAccount() {
me.value = await rpc.query(identityAccountQueries.me())
Expand Down Expand Up @@ -90,8 +91,7 @@ async function setRepoIndexing(repoIndexing: boolean) {

const removeLikeMutation = useNuxtMutation<LikedSkill, { ok: true }>({
mutation: async (skill) => {
const path = `/api/me/likes/${skill.owner}/${skill.repo}/${skill.name}`
const result = await $fetch<{ ok: true }>(path, { method: 'DELETE' })
const result = await removeLike(skill)
await Promise.all([refreshLikes(), refreshSubscriptions()])
return result
},
Expand Down
27 changes: 27 additions & 0 deletions test/unit/like-button.nuxt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,33 @@ describe('likeButton toggle semantics', () => {
})

describe('useLikes hydration', () => {
it('keeps the heart and count when account removal fails', async () => {
likedItems = [{ ...skill, likeCount: 3 }]
const likes = useLikes()
await likes.ensureLoaded()
mutation = { _tag: 'error' }

await expect(likes.remove(skill)).rejects.toThrow('offline')

expect(likes.isLiked(skill)).toBe(true)
expect(likes.likeCount(skill)).toBe(3)
})

it('does not let an in-flight list restore a removed like', async () => {
likedItems = [{ ...skill, likeCount: 3 }]
mutation = { _tag: 'ok', likeCount: 2 }
const release = holdListRequest()
const likes = useLikes()
const loading = likes.ensureLoaded()
const removing = likes.remove(skill)

release()
await Promise.all([loading, removing])

expect(likes.isLiked(skill)).toBe(false)
expect(likes.likeCount(skill)).toBe(2)
})

it('clears the failed like after retry without dismissing another action', async () => {
const likes = useLikes()
mutation = { _tag: 'error' }
Expand Down
48 changes: 46 additions & 2 deletions test/unit/login-benefits.nuxt.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,58 @@
import { mockNuxtImport, mountSuspended } from '@nuxt/test-utils/runtime'
import { describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import Login from '../../layers/identity/app/pages/login.vue'

mockNuxtImport('useUserSession', () => () => ({ loggedIn: ref(false), ready: ref(true), fetch: async () => {} }))
const mocks = vi.hoisted(() => ({ signedIn: false, navigate: vi.fn() }))
mockNuxtImport('navigateTo', () => mocks.navigate)
mockNuxtImport('useUserSession', () => () => ({ loggedIn: ref(mocks.signedIn), ready: ref(true), fetch: async () => {} }))
mockNuxtImport('useAuth', () => () => ({
loginUrl: ({ returnTo, action }: { returnTo: string, action: string }) => `/auth/github?${new URLSearchParams({ return_to: returnTo, action })}`,
}))

describe('account entry', () => {
beforeEach(() => {
mocks.signedIn = false
mocks.navigate.mockReset()
})

it.each(['like-skill', 'watch-skill', 'watch-collection'])('keeps %s when a stale anonymous page reaches an existing session', async (action) => {
mocks.signedIn = true
const returnTo = action === 'watch-collection' ? '/@octocat/tools' : '/gh/nuxt/ui/motion'
const wrapper = await mountSuspended(Login, {
route: `/login?${new URLSearchParams({ return_to: returnTo, action })}`,
})

expect(mocks.navigate).toHaveBeenCalledWith(
`/auth/github?${new URLSearchParams({ return_to: returnTo, action })}`,
{ external: true, replace: true },
)
wrapper.unmount()
})

it('leaves a failed replay available to retry instead of looping through OAuth', async () => {
mocks.signedIn = true
const wrapper = await mountSuspended(Login, {
route: '/login?return_to=%2Fgh%2Fnuxt%2Fui%2Fmotion&action=like-skill&error=oauth',
})

expect(mocks.navigate).not.toHaveBeenCalled()
expect(wrapper.get('[role="alert"]').text()).toContain('Try again.')
expect(wrapper.findAll('a').find(link => link.text().includes('Continue with GitHub'))!.attributes('href'))
.toContain('action=like-skill')
wrapper.unmount()
})

it.each(['', '&action=unknown'])('returns a signed-in visitor directly when there is no supported action%s', async (action) => {
mocks.signedIn = true
const wrapper = await mountSuspended(Login, {
route: `/login?return_to=%2Fgh%2Fnuxt%2Fui${action}`,
})

expect(mocks.navigate).toHaveBeenCalledWith('/gh/nuxt/ui', { replace: true })
wrapper.unmount()
})

it('preserves a failed like intent when switching from sign-in to sign-up', async () => {
const wrapper = await mountSuspended(Login, {
route: '/login?return_to=%2Fgh%2Fnuxt%2Fui%3Ffrom%3Dsearch&action=like-skill&error=oauth',
Expand Down
49 changes: 47 additions & 2 deletions test/unit/me-watchlist.nuxt.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { mockNuxtImport, mountSuspended } from '@nuxt/test-utils/runtime'
import { flushPromises } from '@vue/test-utils'
import { nextTick, ref } from 'vue'
import LikeButton from '../../layers/identity/app/components/LikeButton.vue'
import { useLikes } from '../../layers/identity/app/composables/useLikes'

const oldSyncTime = 1_755_000_000
const newSyncTime = 1_786_000_000
Expand Down Expand Up @@ -65,6 +67,12 @@ vi.mock('@harlan-zw/nuxt-use-query/rpc', async (importOriginal) => {

mockNuxtImport('$fetch', () => requestFetch)

mockNuxtImport('useAuth', () => () => ({
state: ref({ _tag: 'signed-in', user: { login: 'harlan', onboarded: true } }),
isAuthenticated: ref(true),
fetchSession: vi.fn(),
}))

mockNuxtImport('useNuxtRpcQuery', () => {
return (operation: { path: string }) => {
if (operation.path === '/api/me/validation')
Expand Down Expand Up @@ -172,6 +180,10 @@ describe('account skill watchlist', () => {
likesStatus.value = 'success'
refreshLikes.mockClear()
requestFetch.mockReset()
useState('skill-likes').value = {}
useState('skill-like-counts').value = {}
useState('skill-likes-loaded').value = false
useState('skill-likes-pending').value = {}
executeRpc.mockReset()
queryRpc.mockReset()

Expand All @@ -183,7 +195,14 @@ describe('account skill watchlist', () => {
throw new Error(`Unexpected query: ${operation.path}`)
})

requestFetch.mockImplementation(async (path: string) => {
requestFetch.mockImplementation(async (path: string, options?: { method?: string }) => {
if (path === '/api/me/likes') {
if (options?.method === 'POST') {
serverLikes = [likedSkillFixture()]
return { ok: true, likeCount: 1 }
}
return { items: serverLikes.map(skill => ({ ...skill, likeCount: 1 })) }
}
if (path === '/api/me/stars/sync') {
serverAccount = accountFixture(newSyncTime)
return {
Expand All @@ -201,7 +220,7 @@ describe('account skill watchlist', () => {
await new Promise<void>(resolve => releaseDelete = resolve)
serverLikes = []
serverSubscriptions = []
return { ok: true }
return { ok: true, likeCount: 0 }
}
throw new Error(`Unexpected request: ${path}`)
})
Expand Down Expand Up @@ -278,6 +297,32 @@ describe('account skill watchlist', () => {
expect(wrapper.text()).not.toContain('antfu/skills')
})

it('clears the Skill heart after account removal and lets the next click save it again', async () => {
const skill = likedSkillFixture()
await useLikes().ensureLoaded()
const wrapper = await mountPage()

await wrapper.get('button[aria-label="Remove nuxt from your skills"]').trigger('click')
await flushPromises()
wrapper.unmount()

const heart = await mountSuspended(LikeButton, { props: { ...skill, count: 1 } })
await flushPromises()
expect(heart.get('button').attributes('aria-pressed')).toBe('false')
expect(heart.get('button').text()).toBe('0')

requestFetch.mockClear()
await heart.get('button').trigger('click')
await flushPromises()
expect(requestFetch).toHaveBeenCalledWith('/api/me/likes', {
method: 'POST',
body: { owner: skill.owner, repo: skill.repo, name: skill.name },
})
expect(heart.get('button').attributes('aria-pressed')).toBe('true')
expect(serverLikes.map(item => item.name)).toEqual(['nuxt'])
heart.unmount()
})

it('disables skill removal while the request is pending', async () => {
releaseDelete = () => {}
const wrapper = await mountPage()
Expand Down
Loading