Skip to content

Commit

Permalink
feat: create job offer image table & add test case for image size limit
Browse files Browse the repository at this point in the history
  • Loading branch information
DipandaAser committed Aug 27, 2023
1 parent ee1e067 commit b883066
Show file tree
Hide file tree
Showing 8 changed files with 164 additions and 69 deletions.
Binary file added backend/e2etests/image-5_6MB.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
29 changes: 29 additions & 0 deletions backend/e2etests/job_offers.test.after.js
Original file line number Diff line number Diff line change
Expand Up @@ -460,5 +460,34 @@ describe(`${endpoint}`, function () {
.expect(400)
.expect("Content-Type", "application/json; charset=utf-8")
});

it("post job offer with image exceeded size of 5MB", async function () {
let fileData = fs.readFileSync("./image-5_6MB.jpg")
let image = Array.from(fileData)

return request(apiHost)
.post(`${endpoint}`)
.set("Accept", "application/json")
.send({
company_email: "ossdevs-cm@gmail.com",
company_name: "OssCameroon",
company_image: image,
job_title: "Frontend Dev",
is_remote: true,
city: "Douala",
country: "Cameroon",
salary_range_min: 100000,
salary_range_max: 150000,
department: "Research and Development",
description: "OssCameroon is hiring a Remote Go Backend Engineer \n Remote - We are looking for a backend engineer who can work 30+ hr/weekOur ideal candidate has:- 1+ years experience writing in Go (golang.org)- 2-3 years experience writing REST APIs- Experience working at a small startup- A passion for building something meaningful … Salary and compensation No salary data published by company so we estimated salary based on similar jobs related to Golang, Engineer and Backend jobs that are similar: $70,000 — $120000/year",
benefits: "Health insurance, dental insurance, 401k",
how_to_apply: "Please submit your resume and cover letter.",
application_url: "",
application_email_address: "ossdevs-cm@gmail.com",
application_phone_number: "555-555-5555",
tags: "remote, golang, backend, engineer"
})
.expect(413)
});
});
});
83 changes: 49 additions & 34 deletions backend/internal/handlers/job_offers_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ func PostJobOffer(c *gin.Context) {
query := v1beta.OfferPostQuery{}
if err := c.ShouldBind(&query); err != nil {
log.Error(err)
// we check if the resuest body limiter middleware already responded with a 413 error. We do this avoid error when overrriding HTTP headers and body response
if c.Writer.Status() == http.StatusRequestEntityTooLarge {
return
}
c.JSON(http.StatusBadRequest,
gin.H{"error": fmt.Sprintf("could not post job offer: %s", err.Error())})
return
Expand All @@ -82,29 +86,6 @@ func PostJobOffer(c *gin.Context) {
return
}

var uploadImageFunc func(int64) (string, error)
// if we have an image, we need to upload it
if len(query.CompanyImage) > 0 {
// check if the image provided is a valid image and have the right extension/mime type
extensionDeatils := mimetype.Detect([]byte(query.CompanyImage))
if _, ok := allowedEtensions[extensionDeatils.Extension()]; !ok {
log.Error(errors.New("wrong image extension"))
c.JSON(http.StatusBadRequest,
gin.H{"error": "provided image has wrong extension"})
return
}

uploadImageFunc = func(jobOfferID int64) (string, error) {
fs, err := server.GetDefaultFileStorage()
if err != nil {
return "", err
}
return fs.UploadJobOfferCompanyPicture(query.CompanyImage, jobOfferID, extensionDeatils.String(), extensionDeatils.Extension())
}
} else {
uploadImageFunc = nil
}

//Initialize db client
db, err := server.GetDefaultDBClient()
if err != nil {
Expand All @@ -114,14 +95,54 @@ func PostJobOffer(c *gin.Context) {
return
}

offer, err := db.PostJobOffer(query, uploadImageFunc)
offer, err := db.PostJobOffer(query)
if err != nil {
log.Error(err)
c.JSON(http.StatusInternalServerError,
gin.H{"error": "could not post job offer"})
return
}

// if we have an image, we need to upload it
var hasImage bool
if len(query.CompanyImage) > 0 {
// check if the image provided is a valid image and have the right extension/mime type
extensionDeatils := mimetype.Detect([]byte(query.CompanyImage))
if _, ok := allowedEtensions[extensionDeatils.Extension()]; !ok {
log.Error(errors.New("wrong image extension"))
c.JSON(http.StatusBadRequest,
gin.H{"error": "provided image has wrong extension"})
return
}

fs, err := server.GetDefaultFileStorage()
if err != nil {
log.Error(err)
c.JSON(http.StatusInternalServerError,
gin.H{"error": "could not post job offer. Image upload failed"})
return
}
location, err := fs.UploadJobOfferCompanyPicture(query.CompanyImage, offer.ID, extensionDeatils.String(), extensionDeatils.Extension())
if err != nil {
db.DeleteJobOffer(offer.ID)
log.Error(err)
c.JSON(http.StatusInternalServerError,
gin.H{"error": "could not post job offer. Image upload failed"})
return
}

err = db.PostJobOfferImage(offer.ID, location)
if err != nil {
db.DeleteJobOffer(offer.ID)
log.Error(err)
c.JSON(http.StatusInternalServerError,
gin.H{"error": "could not post job offer image"})
return
}

hasImage = true
}

c.JSON(http.StatusCreated, v1beta.JobOfferPresenter{
ID: offer.ID,
CreatedAt: offer.CreatedAt,
Expand All @@ -141,7 +162,7 @@ func PostJobOffer(c *gin.Context) {
ApplicationEmailAddress: offer.ApplicationEmailAddress,
ApplicationPhoneNumber: offer.ApplicationPhoneNumber,
Tags: offer.Tags,
HasImage: offer.CompanyImageLocation != "",
HasImage: hasImage,
})
}

Expand All @@ -164,17 +185,11 @@ func GetJobOfferImage(c *gin.Context) {
return
}

jobOffer, err := db.GetJobOfferById(jobOfferID)
jobOfferImage, err := db.GetJobOfferImageByJobOfferId(jobOfferID)
if err != nil {
log.Error(err)
c.JSON(http.StatusNotFound,
gin.H{"error": "could not find job offer"})
return
}

if jobOffer.CompanyImageLocation == "" {
c.JSON(http.StatusNotFound,
gin.H{"error": "job offer does not have an image"})
gin.H{"error": "could not find job offer image for this job offer"})
return
}

Expand All @@ -186,7 +201,7 @@ func GetJobOfferImage(c *gin.Context) {
return
}

image, err := fs.DownloadJobOfferCompanyPicture(jobOffer.CompanyImageLocation)
image, err := fs.DownloadJobOfferCompanyPicture(jobOfferImage.ImageLocation)
if err != nil {
log.Error(err)
c.JSON(http.StatusInternalServerError,
Expand Down
76 changes: 53 additions & 23 deletions backend/internal/storage/job_offers_db.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@ func (db DB) GetJobOffers(q v1beta.GetJobOffersQuery) (v1beta.JobOffersResponse,
if err != nil {
return v1beta.JobOffersResponse{}, err
}
j.HasImage = j.CompanyImageLocation != ""
jobOffers = append(jobOffers, j)
}

Expand All @@ -67,9 +66,8 @@ func (db DB) GetJobOffers(q v1beta.GetJobOffersQuery) (v1beta.JobOffersResponse,
}

// PostJobOffer post new job offer
// the uploadImageFunc is a function that will be called after the job offer is created
// we made it to avoid circular dependency between storage and server package
func (db DB) PostJobOffer(query v1beta.OfferPostQuery, uploadImageFunc func(jobOfferID int64) (string, error)) (*v1beta.JobOffer, error) {
func (db DB) PostJobOffer(query v1beta.OfferPostQuery) (*v1beta.JobOffer, error) {
currentTime := time.Now().UTC()
offer := &v1beta.JobOffer{
CompanyName: query.CompanyName,
CompanyEmail: query.CompanyEmail,
Expand All @@ -86,8 +84,8 @@ func (db DB) PostJobOffer(query v1beta.OfferPostQuery, uploadImageFunc func(jobO
ApplicationEmailAddress: query.ApplicationEmailAddress,
ApplicationPhoneNumber: query.ApplicationPhoneNumber,
Tags: query.Tags,
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
CreatedAt: currentTime,
UpdatedAt: currentTime,
}

err := db.c.Transaction(func(tx *gorm.DB) error {
Expand All @@ -104,20 +102,6 @@ func (db DB) PostJobOffer(query v1beta.OfferPostQuery, uploadImageFunc func(jobO
return res.Error
}

if uploadImageFunc != nil {
offer.CompanyImageLocation, err = uploadImageFunc(offer.ID)
if err != nil {
log.Error(err)
return err
}

err = tx.Table("job_offers").Where("id = ?", offer.ID).Update("company_image_location", offer.CompanyImageLocation).Error
if err != nil {
log.Error(err)
return err
}
}

return nil
})
if err != nil {
Expand All @@ -127,6 +111,36 @@ func (db DB) PostJobOffer(query v1beta.OfferPostQuery, uploadImageFunc func(jobO
return offer, nil
}

// PostJobOfferImage save job offer image location in the database
func (db DB) PostJobOfferImage(offerID int64, imageLocation string) error {
currentTime := time.Now().UTC()
jobOfferImage := v1beta.JobOfferImage{
JobOfferID: offerID,
ImageLocation: imageLocation,
CreatedAt: currentTime,
UpdatedAt: currentTime,
}

res := db.c.Table("job_offers_image").Create(&jobOfferImage)
if res.Error != nil {
log.Error(res.Error)
return res.Error
}

return nil
}

// DeleteJobOffer delete job offer
func (db DB) DeleteJobOffer(id int64) error {
res := db.c.Table("job_offers").Delete(&v1beta.JobOffer{}, id)
if res.Error != nil {
log.Error(res.Error)
return res.Error
}

return nil
}

func (db DB) queryJobOffers() *gorm.DB {
return db.c.Table("job_offers as jb").Select(`
jb.id,
Expand All @@ -148,10 +162,14 @@ func (db DB) queryJobOffers() *gorm.DB {
jb.application_email_address,
jb.application_phone_number,
jb.tags,
jb.company_image_location,
jt.title as job_title
jt.title as job_title,
CASE
WHEN jb_image.image_location <> '' THEN 1
ELSE 0
END AS has_image
`).
Joins("left join jobtitles as jt on jb.title_id = jt.id")
Joins("left join jobtitles as jt on jb.title_id = jt.id").
Joins("left join job_offers_image as jb_image on jb.id = jb_image.job_offer_id")
}

// GetJobOfferById get job offers by id
Expand All @@ -165,3 +183,15 @@ func (db DB) GetJobOfferById(id int64) (*v1beta.JobOffer, error) {

return &offer, nil
}

// GetJobOfferImageByJobOfferId get job offer image by job offer id
func (db DB) GetJobOfferImageByJobOfferId(jobOfferID int64) (*v1beta.JobOfferImage, error) {
var image v1beta.JobOfferImage
res := db.c.Table("job_offers_image").Where("job_offer_id = ?", jobOfferID).First(&image)
if res.Error != nil {
log.Error(res.Error)
return nil, res.Error
}

return &image, nil
}
5 changes: 4 additions & 1 deletion backend/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@ func main() {
//JobOffers
jobsRouter := router.Group("/jobs")
{
jobsRouter.Use(limits.RequestSizeLimiter(5242880)) // 5MB. if request body is larger than 5MB, it will return 413 error
// 5MB. if request body is larger than 5MB, it will return 413 error.
// The text of the job offers will not be larger than 1MB, because 1 MB = 1024 KB = 1024 * 1024 bytes. So you would need approximately 1 million alphanumeric characters to make 1MB.
// With that information we can fix the size of job offers limit to 4.5MB
jobsRouter.Use(limits.RequestSizeLimiter(5242880))
jobsRouter.GET("", handlers.GetJobOffers)
jobsRouter.POST("", handlers.PostJobOffer)
jobsRouter.GET("/:id/image", handlers.GetJobOfferImage)
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE IF NOT EXISTS job_offers_image (
id BIGSERIAL NOT NULL PRIMARY KEY,
job_offer_id BIGINT NOT NULL,
image_location VARCHAR(255) NOT NULL,
createdat TIMESTAMP WITH TIME ZONE,
updatedat TIMESTAMP WITH TIME ZONE,
CONSTRAINT fk_job_offers
FOREIGN KEY (job_offer_id)
REFERENCES job_offers(id)
);
-- +goose StatementEnd

-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS job_offers_image;
-- +goose StatementEnd
14 changes: 11 additions & 3 deletions backend/pkg/models/v1beta/job_offers.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ type JobOffer struct {

CompanyName string `json:"company_name" gorm:"column:company_name"`
CompanyEmail string `json:"company_email" gorm:"column:company_email"`
CompanyImageLocation string `json:"-" gorm:"column:company_image_location"`
TitleID int64 `json:"title" gorm:"column:title_id"`
IsRemote bool `json:"is_remote" gorm:"column:is_remote"`
City string `json:"city" gorm:"column:city"`
Expand All @@ -31,6 +30,16 @@ type JobOffer struct {
Tags string `json:"tags" gorm:"column:tags"`
}

// JobOfferImage defines the job_offers_image structure
type JobOfferImage struct {
ID int64 `json:"id" gorm:"column:id;primaryKey;autoIncrement:true"`
CreatedAt time.Time `json:"createdat" gorm:"column:createdat"`
UpdatedAt time.Time `json:"updatedat" gorm:"column:updatedat"`

JobOfferID int64 `json:"job_offer_id" gorm:"column:job_offer_id"`
ImageLocation string `json:"image_location" gorm:"column:image_location"`
}

// OfferPostQuery defines the body object used to create a new jb offer on a POST query
type OfferPostQuery struct {
CompanyName string `json:"company_name"`
Expand Down Expand Up @@ -122,8 +131,7 @@ type JobOfferPresenter struct {
ApplicationEmailAddress string `json:"application_email_address"`
ApplicationPhoneNumber string `json:"application_phone_number"`
Tags string `json:"tags"`
HasImage bool `json:"has_image" gorm:"-:all"`
CompanyImageLocation string `json:"-" gorm:"column:company_image_location"`
HasImage bool `json:"has_image" gorm:"column:has_image"`
}

type JobOffersResponse struct {
Expand Down

0 comments on commit b883066

Please # to comment.