feat: track purchase site URL for new components
- New nullable purchase_url column on components - Store/update validators accept an optional purchaseUrl (must be a valid absolute URL with a protocol when provided) - components/create.vue: NewPartSearch import now also fills purchaseUrl from the scraped part's source URL (pc-builder.io product page); the field remains manually editable for second-hand parts entered by hand - components/edit.vue: purchaseUrl is editable after creation - components/show.vue: displays the purchase site as a clickable link when present - 4 new functional tests: create with/without a purchase URL, invalid URL rejected (asserts the Inertia inputErrorsBag flash, not a raw 422 - content negotiation only returns JSON for XHR/API-style requests), and updating an existing component's purchase URL
This commit is contained in:
1 parent
497ae797ca
commit
4174d92711
10 files changed
+174
-6
No files matched your search
@@ -24,6 +24,9 @@ export default class Component extends BaseModel {
|
|||||||
@column()
|
@column()
|
||||||
declare computerId: number | null
|
declare computerId: number | null
|
||||||
|
|
||||||
|
@column()
|
||||||
|
declare purchaseUrl: string | null
|
||||||
|
|
||||||
@column({ serializeAs: null })
|
@column({ serializeAs: null })
|
||||||
declare userId: number
|
declare userId: number
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,13 @@ export const storeComponentValidator = vine.compile(
|
|||||||
price: vine.number().decimal([0, 2]).min(0),
|
price: vine.number().decimal([0, 2]).min(0),
|
||||||
typeId: vine.number().min(1),
|
typeId: vine.number().min(1),
|
||||||
purchaseDate: vine.string().regex(/^\d{4}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])$/),
|
purchaseDate: vine.string().regex(/^\d{4}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])$/),
|
||||||
|
purchaseUrl: vine
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.url({ require_protocol: true })
|
||||||
|
.maxLength(2048)
|
||||||
|
.nullable()
|
||||||
|
.optional(),
|
||||||
quantity: vine.number().min(1).withoutDecimals(),
|
quantity: vine.number().min(1).withoutDecimals(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -6,5 +6,12 @@ export const updateComponentValidator = vine.compile(
|
|||||||
price: vine.number().decimal([0, 2]).min(0),
|
price: vine.number().decimal([0, 2]).min(0),
|
||||||
typeId: vine.number().min(1),
|
typeId: vine.number().min(1),
|
||||||
purchaseDate: vine.string().regex(/^\d{4}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])$/),
|
purchaseDate: vine.string().regex(/^\d{4}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])$/),
|
||||||
|
purchaseUrl: vine
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.url({ require_protocol: true })
|
||||||
|
.maxLength(2048)
|
||||||
|
.nullable()
|
||||||
|
.optional(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||||
|
|
||||||
|
export default class extends BaseSchema {
|
||||||
|
protected tableName = 'components'
|
||||||
|
|
||||||
|
async up() {
|
||||||
|
this.schema.alterTable(this.tableName, (table) => {
|
||||||
|
table.string('purchase_url').nullable()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async down() {
|
||||||
|
this.schema.alterTable(this.tableName, (table) => {
|
||||||
|
table.dropColumn('purchase_url')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-1
@@ -19,7 +19,7 @@ export class ComponentTypeSchema extends BaseModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class ComponentSchema extends BaseModel {
|
export class ComponentSchema extends BaseModel {
|
||||||
static $columns = ['computerId', 'createdAt', 'id', 'name', 'price', 'purchaseDate', 'typeId', 'updatedAt', 'userId'] as const
|
static $columns = ['computerId', 'createdAt', 'id', 'name', 'price', 'purchaseDate', 'purchaseUrl', 'typeId', 'updatedAt', 'userId'] as const
|
||||||
$columns = ComponentSchema.$columns
|
$columns = ComponentSchema.$columns
|
||||||
@column()
|
@column()
|
||||||
declare computerId: number | null
|
declare computerId: number | null
|
||||||
@@ -34,6 +34,8 @@ export class ComponentSchema extends BaseModel {
|
|||||||
@column.date()
|
@column.date()
|
||||||
declare purchaseDate: DateTime
|
declare purchaseDate: DateTime
|
||||||
@column()
|
@column()
|
||||||
|
declare purchaseUrl: string | null
|
||||||
|
@column()
|
||||||
declare typeId: number | null
|
declare typeId: number | null
|
||||||
@column.dateTime({ autoCreate: true, autoUpdate: true })
|
@column.dateTime({ autoCreate: true, autoUpdate: true })
|
||||||
declare updatedAt: DateTime | null
|
declare updatedAt: DateTime | null
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const form = reactive({
|
|||||||
price: 0,
|
price: 0,
|
||||||
typeId: null as number | null,
|
typeId: null as number | null,
|
||||||
purchaseDate: new Date().toISOString().split('T')[0],
|
purchaseDate: new Date().toISOString().split('T')[0],
|
||||||
|
purchaseUrl: '',
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ function importPart(part: ScrapedPart) {
|
|||||||
form.price = part.price
|
form.price = part.price
|
||||||
form.typeId = part.type.id
|
form.typeId = part.type.id
|
||||||
form.purchaseDate = new Date().toISOString().split('T')[0]
|
form.purchaseDate = new Date().toISOString().split('T')[0]
|
||||||
|
form.purchaseUrl = part.sourceUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
function submitForm() {
|
function submitForm() {
|
||||||
@@ -74,6 +76,19 @@ function submitForm() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-gray-700 text-sm font-bold mb-2" for="purchaseUrl"
|
||||||
|
>Purchase Site (optional, filled automatically when importing a new part)</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
name="purchaseUrl"
|
||||||
|
v-model="form.purchaseUrl"
|
||||||
|
class="w-full mt-2 border-gray-200 focus:border-indigo-600 focus:ring focus:ring-indigo-500/40 px-3 py-2 border rounded text-gray-700"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-gray-700 text-sm font-bold mb-2" for="quantity">Quantity</label>
|
<label class="block text-gray-700 text-sm font-bold mb-2" for="quantity">Quantity</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const form = reactive({
|
|||||||
price: component.price,
|
price: component.price,
|
||||||
typeId: component.type.id,
|
typeId: component.type.id,
|
||||||
purchaseDate: component.purchaseDate,
|
purchaseDate: component.purchaseDate,
|
||||||
|
purchaseUrl: component.purchaseUrl ?? '',
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -48,11 +49,7 @@ const form = reactive({
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-gray-700 text-sm font-bold mb-2" for="type">Type</label>
|
<label class="block text-gray-700 text-sm font-bold mb-2" for="type">Type</label>
|
||||||
<CustomSelect
|
<CustomSelect v-model="form.typeId" :options="types" placeholder="Choose a type" />
|
||||||
v-model="form.typeId"
|
|
||||||
:options="types"
|
|
||||||
placeholder="Choose a type"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -67,6 +64,19 @@ const form = reactive({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-gray-700 text-sm font-bold mb-2" for="purchaseUrl"
|
||||||
|
>Purchase Site (optional)</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
name="purchaseUrl"
|
||||||
|
v-model="form.purchaseUrl"
|
||||||
|
class="w-full mt-2 border-gray-200 focus:border-indigo-600 focus:ring focus:ring-indigo-500/40 px-3 py-2 border rounded text-gray-700"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
:href="`/components/${form.id}`"
|
:href="`/components/${form.id}`"
|
||||||
method="put"
|
method="put"
|
||||||
|
|||||||
@@ -36,6 +36,18 @@ const props = defineProps<{ component: Component }>()
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="props.component.purchaseUrl" class="mb-6">
|
||||||
|
<label class="block text-gray-700 text-sm font-bold mb-2">Purchase Site</label>
|
||||||
|
<a
|
||||||
|
:href="props.component.purchaseUrl"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="block px-3 py-2 border rounded text-indigo-600 hover:text-indigo-800 hover:underline truncate"
|
||||||
|
>
|
||||||
|
{{ props.component.purchaseUrl }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
:href="`/components/${props.component.id}/edit`"
|
:href="`/components/${props.component.id}/edit`"
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export type Component = {
|
|||||||
price: string
|
price: string
|
||||||
computerId: number | null
|
computerId: number | null
|
||||||
purchaseDate: string
|
purchaseDate: string
|
||||||
|
purchaseUrl: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
type: ComponentType
|
type: ComponentType
|
||||||
|
|||||||
@@ -102,3 +102,97 @@ test.group('Components authorization (IDOR)', () => {
|
|||||||
assert.isNotNull(stillExists)
|
assert.isNotNull(stillExists)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test.group('Components purchase URL', () => {
|
||||||
|
test('a component can be created with a purchase site URL', async ({ client, assert }) => {
|
||||||
|
const user = await createUser('purchase-url-a@example.com')
|
||||||
|
const type = await createType('RAM-purchase-1')
|
||||||
|
|
||||||
|
const response = await client
|
||||||
|
.post('/components')
|
||||||
|
.loginAs(user)
|
||||||
|
.withCsrfToken()
|
||||||
|
.redirects(0)
|
||||||
|
.json({
|
||||||
|
name: 'New GPU',
|
||||||
|
price: 599.99,
|
||||||
|
typeId: type.id,
|
||||||
|
purchaseDate: DateTime.now().toFormat('yyyy-MM-dd'),
|
||||||
|
purchaseUrl: 'https://fr.pc-builder.io/gpu/example-p1',
|
||||||
|
quantity: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
response.assertStatus(302)
|
||||||
|
const component = await Component.query().where('userId', user.id).firstOrFail()
|
||||||
|
assert.equal(component.purchaseUrl, 'https://fr.pc-builder.io/gpu/example-p1')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a component can be created without a purchase site URL', async ({ client, assert }) => {
|
||||||
|
const user = await createUser('purchase-url-b@example.com')
|
||||||
|
const type = await createType('RAM-purchase-2')
|
||||||
|
|
||||||
|
const response = await client
|
||||||
|
.post('/components')
|
||||||
|
.loginAs(user)
|
||||||
|
.withCsrfToken()
|
||||||
|
.redirects(0)
|
||||||
|
.json({
|
||||||
|
name: 'Used RAM stick',
|
||||||
|
price: 20,
|
||||||
|
typeId: type.id,
|
||||||
|
purchaseDate: DateTime.now().toFormat('yyyy-MM-dd'),
|
||||||
|
quantity: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
response.assertStatus(302)
|
||||||
|
const component = await Component.query().where('userId', user.id).firstOrFail()
|
||||||
|
assert.isNull(component.purchaseUrl)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an invalid purchase URL is rejected', async ({ client, assert }) => {
|
||||||
|
const user = await createUser('purchase-url-c@example.com')
|
||||||
|
const type = await createType('RAM-purchase-3')
|
||||||
|
|
||||||
|
const response = await client
|
||||||
|
.post('/components')
|
||||||
|
.loginAs(user)
|
||||||
|
.withCsrfToken()
|
||||||
|
.redirects(0)
|
||||||
|
.json({
|
||||||
|
name: 'Suspicious component',
|
||||||
|
price: 20,
|
||||||
|
typeId: type.id,
|
||||||
|
purchaseDate: DateTime.now().toFormat('yyyy-MM-dd'),
|
||||||
|
purchaseUrl: 'not-a-url',
|
||||||
|
quantity: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
response.assertStatus(302)
|
||||||
|
assert.property(response.flashMessage('inputErrorsBag'), 'purchaseUrl')
|
||||||
|
const created = await Component.query().where('userId', user.id).first()
|
||||||
|
assert.isNull(created)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the purchase URL can be updated on an existing component', async ({ client, assert }) => {
|
||||||
|
const owner = await createUser('purchase-url-d@example.com')
|
||||||
|
const type = await createType('RAM-purchase-4')
|
||||||
|
const component = await createComponent(owner.id, type.id)
|
||||||
|
|
||||||
|
const response = await client
|
||||||
|
.put(`/components/${component.id}`)
|
||||||
|
.loginAs(owner)
|
||||||
|
.withCsrfToken()
|
||||||
|
.redirects(0)
|
||||||
|
.json({
|
||||||
|
name: component.name,
|
||||||
|
price: component.price,
|
||||||
|
typeId: type.id,
|
||||||
|
purchaseDate: DateTime.now().toFormat('yyyy-MM-dd'),
|
||||||
|
purchaseUrl: 'https://fr.pc-builder.io/cpu/example-p2',
|
||||||
|
})
|
||||||
|
|
||||||
|
response.assertStatus(302)
|
||||||
|
await component.refresh()
|
||||||
|
assert.equal(component.purchaseUrl, 'https://fr.pc-builder.io/cpu/example-p2')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in new issue
Block a user